diff --git a/handlers/__init__.py b/handlers/__init__.py index 697552f..c88386f 100644 --- a/handlers/__init__.py +++ b/handlers/__init__.py @@ -2,6 +2,15 @@ from .start import router as start_router from .profile import router as profile_router from .subscriptions import router as subscriptions_router from .support import router as support_router +from .referrals import router as referrals_router -# Экспортируем все маршрутизаторы -routers = [start_router,profile_router,subscriptions_router,support_router] +# Список всех роутеров бота +routers = [ + start_router, + profile_router, + subscriptions_router, + support_router, + referrals_router, +] + +__all__ = ["routers"] diff --git a/handlers/profile.py b/handlers/profile.py index fc20016..8a5061d 100644 --- a/handlers/profile.py +++ b/handlers/profile.py @@ -1,3 +1,5 @@ +# Профиль. последнее изменение 24.11.2025 + from aiogram import Router, types from aiogram.types import CallbackQuery import logging @@ -13,6 +15,7 @@ from keyboard.keyboards import ( confirm_popup_keyboard, guide_keyboard, balance_keyboard, + payment_methods_keyboard, ) locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8") @@ -21,18 +24,24 @@ router = Router() logger = logging.getLogger(__name__) -async def call_api(method, endpoint, data=None): +async def call_api(method, endpoint, data=None, base_url=BASE_URL_FASTAPI): """ Выполняет HTTP-запрос к FastAPI. """ - url = f"{BASE_URL_FASTAPI}{endpoint}" + url = f"{base_url}{endpoint}" logger.info(f"Инициализация запроса: {method} {url} с данными {data}") try: async with aiohttp.ClientSession() as session: - async with session.request(method, url, json=data) as response: + async with session.request( + method, + url, + json=data, + headers={"Content-Type": "application/json"} + ) as response: logger.info( - f"Получен ответ от {url}: статус {response.status}") + f"Получен ответ от {url}: статус {response.status}" + ) if response.status in {200, 201}: result = await response.json() @@ -54,7 +63,7 @@ async def call_api(method, endpoint, data=None): async def profile_callback_handler(callback: CallbackQuery): """ Профиль пользователя. - Логика работы с API сохранена, переписан только текст/визуал. + Логика работы с API сохранена, изменён только текст/визуал. """ try: user_data = await call_api("GET", f"/user/{callback.from_user.id}") @@ -65,55 +74,46 @@ async def profile_callback_handler(callback: CallbackQuery): await callback.answer() return - sub_data = await call_api("GET", f"/subscription/{user_data['telegram_id']}/last") + # Последняя подписка пользователя + sub_data = await call_api( + "GET", f"/subscription/{user_data['telegram_id']}/last" + ) if sub_data == "ERROR" or not isinstance(sub_data, dict): sub_data = None - balance_text = f"💰 Баланс: {user_data['balance']} ₽" + username = callback.from_user.username or "-" + balance = user_data.get("balance", 0) - username = callback.from_user.username or "пользователь" + # Статус подписки: Активна / Нет активных + sub_status = "⚫ Нет активных" - # Нет подписки - if not sub_data: - text = ( - f"📜 Профиль {username}\n\n" - f"{balance_text}\n\n" - "У тебя пока нет активной подписки.\n" - "Пополняй баланс и подключай тариф Lark, чтобы получить статус 🐣 или 🦅." - ) - else: - expiry_date = sub_data.get("end_date") - formatted_date = ( - datetime.fromisoformat(expiry_date).strftime("%d %B %Y г.") - if expiry_date - else None - ) - is_expired = ( - datetime.fromisoformat(expiry_date) < datetime.now() - if expiry_date - else True - ) - status_icon = "✖️" if is_expired else "☑️" - profile_status = "🦅" if "Pro" in sub_data.get("plan", "") else "🐣" - - if not is_expired and formatted_date: - sub_text = ( - f"Подписка активна до {formatted_date} {status_icon}\n" - f"Текущий статус: {profile_status}" - ) + if sub_data: + expiry_date = sub_data.get("expiry_date") + if expiry_date: + try: + is_expired = datetime.fromisoformat( + expiry_date) < datetime.now() + except ValueError: + is_expired = True else: - sub_text = ( - f"Подписка неактивна {status_icon}\n" - "Ты можешь продлить её в разделе подписок." - ) + is_expired = True - text = ( - f"📜 Профиль {username} {profile_status}\n\n" - f"{sub_text}\n\n" - f"{balance_text}" - ) + if not is_expired: + sub_status = "🟢 Активна" - await callback.message.edit_text(text, reply_markup=account_keyboard()) + text = ( + "🥚 Профиль\n\n" + f"Пользователь: @{username}\n" + f"Баланс: {balance} ₽\n" + f"Статус подписки: {sub_status}\n\n" + "Выберите действие:" + ) + + await callback.message.edit_text( + text, + parse_mode=ParseMode.HTML, + reply_markup=account_keyboard(), + ) except Exception as e: logger.exception(f"Ошибка в обработчике профиля: {e}") await callback.message.answer("Произошла ошибка. Попробуйте позже.") @@ -124,20 +124,11 @@ async def profile_callback_handler(callback: CallbackQuery): @router.callback_query(lambda callback: callback.data == "balance") async def balance_callback_handler(callback: CallbackQuery): """ - Обработчик callback_query для баланса. + При нажатии «Пополнить баланс» показываем выбор суммы пополнения. """ - user_data = await call_api("GET", f"/user/{callback.from_user.id}") - if not user_data: - await callback.message.answer( - "Произошла ошибка, попробуйте позже или свяжитесь с администрацией." - ) - await callback.answer() - return - await callback.message.edit_text( - f"💰 Текущий баланс: {user_data['balance']} ₽\n\n" - "Выбери сумму, на которую хочешь пополнить счёт.", - reply_markup=balance_keyboard(), + "💳 Выберите сумму пополнения:", + reply_markup=popup_keyboard(), ) await callback.answer() @@ -175,7 +166,9 @@ async def tranhist_callback_handler(callback: CallbackQuery): return try: - transactions = await call_api("GET", f"/user/{user_data['telegram_id']}/transactions") + transactions = await call_api( + "GET", f"/user/{user_data['telegram_id']}/transactions" + ) if not transactions: await callback.message.edit_text( "У вас нет транзакций.", reply_markup=tranhist_keyboard() @@ -200,7 +193,9 @@ async def tranhist_callback_handler(callback: CallbackQuery): ) except Exception as e: logger.error(f"Ошибка обработки транзакций: {e}") - await callback.message.edit_text("Произошла ошибка. Попробуйте позже.") + await callback.message.edit_text( + "Произошла ошибка. Попробуйте позже." + ) finally: await callback.answer() @@ -208,25 +203,173 @@ async def tranhist_callback_handler(callback: CallbackQuery): @router.callback_query(lambda callback: callback.data.startswith("popup:")) async def popup_confirm_callback_handler(callback: CallbackQuery): """ - Обработчик подтверждения пополнения баланса. + После выбора суммы показываем варианты оплаты. + Разрешены только суммы 200, 300, 600, 1000 ₽. """ - data = callback.data.split(":") - popup_info = data[1] - result = await call_api( - "POST", - f"/user/{callback.from_user.id}/balance/{float(popup_info)}", - ) - if result == "ERROR": + try: + _, amount_raw = callback.data.split(":", maxsplit=1) + amount = int(float(amount_raw)) + except Exception: + await callback.message.answer("Некорректная сумма пополнения.") + await callback.answer() + return + + if amount not in {200, 300, 600, 1000}: await callback.message.answer( - "Произошла ошибка, попробуйте позже или свяжитесь с администрацией." + "Эта сумма пополнения недоступна. Выбери вариант от 200 до 1000 ₽." ) await callback.answer() return - text = f"✅ Баланс пополнен на {popup_info} ₽. Спасибо, что остаёшься с Lark." - await callback.message.edit_text( - text=text, reply_markup=confirm_popup_keyboard() + text = ( + f"💰 Сумма пополнения: {amount} ₽\n\n" + "Выбери способ оплаты:" ) + + await callback.message.edit_text( + text=text, + reply_markup=payment_methods_keyboard(amount), + ) + await callback.answer() + + +@router.callback_query(lambda callback: callback.data.startswith("method_stars_")) +async def method_stars_handler(callback: CallbackQuery): + """ + Заглушка: оплата через Telegram Stars. + """ + amount = callback.data.split("_")[-1] + await callback.message.edit_text( + f"⭐ Оплата через Telegram Stars на {amount} ₽ пока в разработке.\n\n" + "Позже сюда подвяжем реальный платёж.", + ) + await callback.answer() + + +@router.callback_query(lambda callback: callback.data.startswith("method_ykassa_")) +async def method_ykassa_handler(callback: CallbackQuery): + """ + Обработчик оплаты через YooKassa. + """ + amount = callback.data.split("_")[-1] + + # Сразу отвечаем на callback, чтобы Telegram не считал запрос "старым" + try: + await callback.answer() + except Exception: + # Если по какой-то причине уже отвечали — просто игнорируем + pass + + # Формируем URL с query parameters вместо JSON body + endpoint = ( + f"/billing/payments/init?" + f"user_id={callback.from_user.id}&amount={float(amount)}&provider=yookassa" + ) + + logger.info(f"Отправка запроса на инициализацию платежа: {endpoint}") + + # Отправляем POST запрос с пустым телом (параметры в URL) + result = await call_api("POST", endpoint, None, "http://billing:8000") + + # Биллинг вообще не ответил/упал + if result == "ERROR" or not isinstance(result, dict): + await callback.message.edit_text( + "❌ Произошла ошибка при создании платежа. Попробуйте позже." + ) + return + + # Биллинг вернул ошибку (success = False) + if not result.get("success", False): + error_msg = ( + result.get("error") + or result.get("detail") + or "Неизвестная ошибка" + ) + await callback.message.edit_text(f"❌ Ошибка: {error_msg}") + return + + payment_url = result.get("confirmation_url", "#") + payment_id = result.get("payment_id", "") + + await callback.message.edit_text( + f"💵 Оплата через YooKassa\n\n" + f"💰 Сумма: {amount} руб\n" + f"📋 ID платежа: {payment_id}\n\n" + f"➡️ Перейти к оплате\n\n" + f"После оплаты нажмите кнопку 'Проверить оплату'", + parse_mode=ParseMode.HTML, + disable_web_page_preview=True, + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[ + types.InlineKeyboardButton( + text="🔄 Проверить оплату", + callback_data=f"check_payment:{payment_id}", + ) + ]] + ), + ) + + await callback.answer() + + +@router.callback_query(lambda callback: callback.data.startswith("method_crypto_")) +async def method_crypto_handler(callback: CallbackQuery): + """ + Оплата через CryptoBot. + """ + amount = callback.data.split("_")[-1] + + # Сразу отвечаем на callback, чтобы избежать таймаута + try: + await callback.answer() + except Exception: + pass + + endpoint = ( + f"/billing/payments/init?" + f"user_id={callback.from_user.id}&amount={float(amount)}&provider=cryptobot" + ) + + logger.info(f"Отправка запроса на инициализацию платежа: {endpoint}") + + result = await call_api("POST", endpoint, None, "http://billing:8000") + + if result == "ERROR" or not isinstance(result, dict): + await callback.message.edit_text( + "❌ Произошла ошибка при создании платежа. Попробуйте позже." + ) + return + + if not result.get("success", False): + error_msg = ( + result.get("error") + or result.get("detail") + or "Неизвестная ошибка" + ) + await callback.message.edit_text(f"❌ Ошибка: {error_msg}") + return + + payment_url = result.get("confirmation_url", "#") + payment_id = result.get("payment_id", "") + + await callback.message.edit_text( + f"💵 Оплата через Сryptobot\n\n" + f"💰 Сумма: {amount} руб\n" + f"📋 ID платежа: {payment_id}\n\n" + f"➡️ Перейти к оплате\n\n" + f"После оплаты нажмите кнопку 'Проверить оплату'", + parse_mode=ParseMode.HTML, + disable_web_page_preview=True, + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[ + types.InlineKeyboardButton( + text="🔄 Проверить оплату", + callback_data=f"check_payment:{payment_id}", + ) + ]] + ), + ) + await callback.answer() diff --git a/handlers/referrals.py b/handlers/referrals.py new file mode 100644 index 0000000..283891a --- /dev/null +++ b/handlers/referrals.py @@ -0,0 +1,59 @@ +from aiogram import Router, types +from aiogram.filters import Command +from aiogram.enums.parse_mode import ParseMode +import logging + +router = Router() +logger = logging.getLogger(__name__) + + +async def _build_referral_text(bot, user_id: int) -> str: + me = await bot.get_me() + bot_username = me.username or "LarkVPN_bot" + + link = f"https://t.me/{bot_username}?start=ref_{user_id}" + + text = ( + "👥 Реферальная программа\n\n" + "Зови друзей в Lark VPN и получай бонусы на баланс.\n\n" + f"🔗 Твоя ссылка:\n{link}\n\n" + "👤 Приглашено: —\n" + "💰 Начислено бонусов: — ₽\n\n" + "Бонусы падают автоматически, когда приглашённые пополняют баланс." + ) + return text + + +@router.message(Command("referrals")) +async def referrals_command(message: types.Message): + """ + Команда /referrals — показывает текст реферальной программы. + """ + logger.info(f"Получена команда /referrals от {message.from_user.id}") + try: + text = await _build_referral_text(message.bot, message.from_user.id) + await message.answer(text, parse_mode=ParseMode.HTML) + except Exception as e: + logger.exception(f"Ошибка в обработчике /referrals: {e}") + await message.answer("Произошла ошибка. Попробуй позже.") + + +@router.callback_query(lambda callback: callback.data == "referral") +async def referrals_callback(callback: types.CallbackQuery): + """ + Кнопка «Реферальная программа» в главном меню. + """ + try: + text = await _build_referral_text( + callback.message.bot, + callback.from_user.id, + ) + await callback.message.edit_text( + text, + parse_mode=ParseMode.HTML, + ) + except Exception as e: + logger.exception(f"Ошибка в обработчике callback 'referral': {e}") + await callback.message.answer("Произошла ошибка. Попробуй позже.") + finally: + await callback.answer() diff --git a/handlers/start.py b/handlers/start.py index 65b6f29..69c20eb 100644 --- a/handlers/start.py +++ b/handlers/start.py @@ -5,6 +5,8 @@ import logging from instences.config import BASE_URL_FASTAPI import aiohttp from keyboard.keyboards import main_keyboard +from aiogram.enums.parse_mode import ParseMode +from .referrals import _build_referral_text router = Router() logger = logging.getLogger(__name__) @@ -25,7 +27,8 @@ async def call_api(method, endpoint, data=None): async with aiohttp.ClientSession() as session: async with session.request(method, url, json=data) as response: logger.info( - f"Получен ответ от {url}: статус {response.status}") + f"Получен ответ от {url}: статус {response.status}" + ) if response.status in {200, 201}: result = await response.json() @@ -35,7 +38,8 @@ async def call_api(method, endpoint, data=None): logger.debug(f"Код {response.status}, возвращаю ничего") return None logger.error( - f"Ошибка в запросе: статус {response.status}, причина {response.reason}") + f"Ошибка в запросе: статус {response.status}, причина {response.reason}" + ) return "ERROR" except Exception as e: logger.exception(f"Исключение при выполнении запроса к {url}: {e}") @@ -43,18 +47,11 @@ async def call_api(method, endpoint, data=None): def _welcome_text(username: str | None) -> str: - uname = username or "птенец" - return ( - f"🪽 Добро пожаловать в Lark VPN, {uname}!\n\n" - "Здесь ты получаешь стабильный доступ к TikTok, YouTube, Discord, Instagram " - "и другим сервисам без ограничений.\n\n" - "1️⃣ Пополни баланс\n" - "2️⃣ Выбери подходящий тариф\n" - "3️⃣ Подключай конфиг на своих устройствах и забудь про блокировки.\n\n" - "Следи за акциями и розыгрышами в канале:\n" - "https://t.me/+0z5xqn3F1m02OTJi\n\n" - "Lark держит связь. 🐣🦅" - ) + """ + Текст приветствия в /start и в главном меню. + Имя пока не используем — оставляем сигнатуру на будущее. + """ + return "🥚 Lark Security\n\nВыберите действие из меню ниже." @router.message(Command("start")) @@ -72,8 +69,13 @@ async def start_command(message: Message): user_data = await call_api("GET", f"/user/{message.from_user.id}") if not user_data: logger.debug( - "Пользователь не найден в базе, создаем новую запись.") - await call_api("POST", "/user/create", {"telegram_id": message.from_user.id}) + "Пользователь не найден в базе, создаем новую запись." + ) + await call_api( + "POST", + "/user/create", + {"telegram_id": message.from_user.id}, + ) logger.debug("Отправка приветственного сообщения пользователю.") await message.answer( @@ -89,6 +91,28 @@ async def start_command(message: Message): await message.answer("Произошла ошибка. Попробуйте позже.") +@router.message(Command("referrals")) +async def referrals_menu_command(message: Message): + """ + Команда /referrals из бокового меню Telegram. + Показывает текст реферальной программы. + """ + logger.info( + f"Получена команда /referrals от пользователя: " + f"{message.from_user.id} ({message.from_user.username})" + ) + try: + text = await _build_referral_text(message.bot, message.from_user.id) + await message.answer(text, parse_mode=ParseMode.HTML) + logger.info("Реферальная программа отправлена пользователю.") + except Exception as e: + logger.exception( + f"Ошибка при обработке команды /referrals для пользователя " + f"{message.from_user.id}: {e}" + ) + await message.answer("Произошла ошибка. Попробуйте позже.") + + @router.callback_query(lambda callback: callback.data == "base") async def start_callback_handler(callback: CallbackQuery): """ diff --git a/handlers/subscriptions.py b/handlers/subscriptions.py index e9f1952..da28b62 100644 --- a/handlers/subscriptions.py +++ b/handlers/subscriptions.py @@ -1,168 +1,512 @@ from aiogram import Router, types -import logging -from instences.config import BASE_URL_FASTAPI -import aiohttp from aiogram.enums.parse_mode import ParseMode from aiogram.filters import Command -from keyboard.keyboards import tarif_Lark_pro_keyboard, tarif_Lark_keyboard, tarif_confirm_keyboard,buy_keyboard +import logging +from datetime import datetime +import aiohttp + +from instences.config import BASE_URL_FASTAPI +from keyboard.keyboards import ( + buy_keyboard, + tarif_Lark_keyboard, + tarif_Lark_pro_keyboard, + tarif_Lark_family_keyboard, + tarif_confirm_keyboard, + subscriptions_card_keyboard, +) router = Router() logger = logging.getLogger(__name__) -async def call_api(method, endpoint, data=None): + +async def call_api(method: str, endpoint: str, data: dict | None = None): """ - Выполняет HTTP-запрос к FastAPI. + Унифицированный вызов backend API. """ url = f"{BASE_URL_FASTAPI}{endpoint}" - logger.info(f"Инициализация запроса: {method} {url} с данными {data}") + logger.info(f"API {method} {url} data={data}") try: async with aiohttp.ClientSession() as session: - async with session.request(method, url, json=data) as response: - logger.info(f"Получен ответ от {url}: статус {response.status}") - - if response.status in {200, 201}: - result = await response.json() - logger.debug(f"Ответ JSON: {result}") - return result - if response.status in {404,400}: - result = await response.json() - logger.debug(f"Код {response.status}, возвращаю {result}") - return result - logger.error(f"Ошибка в запросе: статус {response.status}, причина {response.reason}") + async with session.request(method, url, json=data) as resp: + logger.info(f"API response {resp.status} {resp.reason}") + if resp.status in {200, 201}: + return await resp.json() + if resp.status in {400, 404}: + # Возвращаем JSON с detail, чтобы разобрать ошибку + try: + return await resp.json() + except Exception: + return None + logger.error(f"Unexpected status {resp.status}: {await resp.text()}") return "ERROR" except Exception as e: - logger.exception(f"Исключение при выполнении запроса к {url}: {e}") + logger.exception(f"API exception {url}: {e}") return "ERROR" + def escape_markdown_v2(text: str) -> str: """ - Экранирует специальные символы для Markdown_V2. + Экранирует спецсимволы для Markdown_V2. """ - special_chars = r"_*[]()~`>#+-=|{}.!" - for char in special_chars: - text = text.replace(char, f"\\{char}") + special_chars = r"_*[]()~`>#+-=|{}.!\\" + for ch in special_chars: + text = text.replace(ch, f"\\{ch}") return text +def _plan_human_title(plan: str) -> str: + """ + Красивое имя тарифа по plan-строке из БД. + """ + if not plan: + return "—" + if plan.startswith("Lark_Standart"): + return "Lark Basic" + if plan.startswith("Lark_Pro"): + return "Lark Pro" + if plan.startswith("Lark_Family"): + return "Lark Family" + return plan + + +async def _fetch_user_and_subs(telegram_id: int): + """ + Берём пользователя и список его подписок с backend. + """ + user = await call_api("GET", f"/user/{telegram_id}") + if not user or not isinstance(user, dict): + return None, [] + + user_id = user["id"] + subs = await call_api("GET", f"/subscriptions/{user_id}") + + if subs == "ERROR" or not isinstance(subs, list): + subs = [] + + return user, subs + + +async def _show_subscriptions_view( + msg: types.Message, + telegram_id: int, + index: int = 0, + edit: bool = False, +): + """ + Рендер карточки подписки: навигация + конфиг + продление. + """ + user, subs = await _fetch_user_and_subs(telegram_id) + + if not user: + text = "Не удалось получить данные пользователя. Попробуй чуть позже." + if edit: + await msg.edit_text(text) + else: + await msg.answer(text) + return + + if not subs: + text = ( + "⚪ У тебя пока нет активных подписок.\n\n" + "Можешь подключить тариф через меню подписок." + ) + if edit: + await msg.edit_text( + text, + parse_mode=ParseMode.HTML, + reply_markup=buy_keyboard(), + ) + else: + await msg.answer( + text, + parse_mode=ParseMode.HTML, + reply_markup=buy_keyboard(), + ) + return + + # Нормализуем индекс + index = max(0, min(index, len(subs) - 1)) + sub = subs[index] + + plan_title = _plan_human_title(sub.get("plan")) + expiry_raw = sub.get("expiry_date") + date_str = "—" + days_left = None + status_emoji = "🟢" + + if expiry_raw: + try: + dt = datetime.fromisoformat(expiry_raw) + date_str = dt.strftime("%d.%m.%Y") + now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now() + days_left = (dt.date() - now.date()).days + if days_left < 0: + status_emoji = "⚫" + else: + status_emoji = "🟢" + except ValueError: + status_emoji = "⚪" + + lines = [ + f"{status_emoji} Подписка", + f"Тариф: {plan_title}", + ] + + if days_left is None: + lines.append(f"Действует до {date_str}") + else: + if days_left >= 0: + lines.append(f"Действует до {date_str} (ещё {days_left} дн.)") + else: + lines.append(f"Истекла {date_str}") + + lines.append("") + lines.append(f"{index + 1} из {len(subs)}") + + text = "\n".join(lines) + + kb = subscriptions_card_keyboard( + sub_id=sub["id"], + index=index, + total=len(subs), + ) + + if edit: + await msg.edit_text(text, parse_mode=ParseMode.HTML, reply_markup=kb) + else: + await msg.answer(text, parse_mode=ParseMode.HTML, reply_markup=kb) + + +# ===== Мои подписки / карточка ===== @router.message(Command("subscriptions")) -async def supp(message: types.Message): +async def cmd_subscriptions(message: types.Message): """ - Меню системы подписок + /subscriptions — открыть карточку подписок. """ - text = "" - uri = None # Инициализация переменной - try: - # Вызов API для получения URI - result = await call_api("GET", f"/uri?telegram_id={message.from_user.id}") - uri = result.get('detail', "Error") # Получаем URI из ответа или "Error", если ключ отсутствует - - # Проверка результата - if uri == "Error": - text = escape_markdown_v2("Произошла ошибка при получении URI") - elif uri == "SUB_ERROR": - text = escape_markdown_v2("Вы ещё не приобрели подписки!!") - elif "trojan" in uri: - escaped_uri = escape_markdown_v2(uri) # Экранирование URI - text = f"Ваша подписка: ```{escaped_uri}```" - else: - text = escape_markdown_v2("Произошла ошибка при обработке URI") - except Exception as e: - # Логирование ошибок - logger.error(f"Ошибка при вызове API для подписки: {e}") - text = escape_markdown_v2("Произошла неожиданная ошибка при получении подписки.") - - # Ответ пользователю - await message.answer( - text, - parse_mode=ParseMode.MARKDOWN_V2 + await _show_subscriptions_view( + msg=message, + telegram_id=message.from_user.id, + index=0, + edit=False, ) + +@router.callback_query(lambda c: c.data == "go_subscriptions") +async def cb_go_subscriptions(callback: types.CallbackQuery): + """ + Кнопка из других экранов — перейти в 'Мои подписки'. + """ + await _show_subscriptions_view( + msg=callback.message, + telegram_id=callback.from_user.id, + index=0, + edit=True, + ) + await callback.answer() + + +@router.callback_query(lambda c: c.data.startswith("sub_prev:")) +async def cb_sub_prev(callback: types.CallbackQuery): + """ + Навигация: предыдущая подписка. + """ + try: + index = int(callback.data.split(":")[1]) + except (IndexError, ValueError): + index = 0 + + await _show_subscriptions_view( + msg=callback.message, + telegram_id=callback.from_user.id, + index=index, + edit=True, + ) + await callback.answer() + + +@router.callback_query(lambda c: c.data.startswith("sub_next:")) +async def cb_sub_next(callback: types.CallbackQuery): + """ + Навигация: следующая подписка. + """ + try: + index = int(callback.data.split(":")[1]) + except (IndexError, ValueError): + index = 0 + + await _show_subscriptions_view( + msg=callback.message, + telegram_id=callback.from_user.id, + index=index, + edit=True, + ) + await callback.answer() + + +@router.callback_query(lambda c: c.data.startswith("sub_cfg:")) +async def cb_sub_cfg(callback: types.CallbackQuery): + """ + Кнопка 'Конфиг' в карточке подписки. + Тянем URI через /uri?telegram_id=... + """ + result = await call_api( + "GET", + f"/uri?telegram_id={callback.from_user.id}", + ) + + if result == "ERROR" or not isinstance(result, dict): + await callback.message.answer( + "Не удалось получить конфиг. Попробуй позже." + ) + await callback.answer() + return + + detail = result.get("detail") + + if detail == "SUB_ERROR": + await callback.message.answer( + "У тебя нет активной подписки для генерации конфига." + ) + await callback.answer() + return + + if not isinstance(detail, str) or not detail: + await callback.message.answer( + "Что-то пошло не так при выдаче конфига." + ) + await callback.answer() + return + + escaped = escape_markdown_v2(detail) + text = f"Твой конфиг:\n```{escaped}```" + + await callback.message.answer( + text, + parse_mode=ParseMode.MARKDOWN_V2, + ) + await callback.answer() + + +@router.callback_query(lambda c: c.data.startswith("sub_renew:")) +async def cb_sub_renew(callback: types.CallbackQuery): + """ + Кнопка 'Продлить' в карточке подписки. + По факту — повторный /subscription/buy с тем же планом. + """ + sub_id = callback.data.split(":", maxsplit=1)[ + 1] if ":" in callback.data else None + user, subs = await _fetch_user_and_subs(callback.from_user.id) + + if not user or not subs: + await callback.message.answer("Подписка не найдена.") + await callback.answer() + return + + target = next((s for s in subs if s.get("id") == sub_id), None) + if not target: + await callback.message.answer("Подписка не найдена.") + await callback.answer() + return + + plan_id = target.get("plan") + if not plan_id: + await callback.message.answer("Не удалось определить тариф для продления.") + await callback.answer() + return + + result = await call_api( + "POST", + "/subscription/buy", + {"telegram_id": str(callback.from_user.id), "plan_id": plan_id}, + ) + + # Ошибки backend (detail) + if result == "ERROR" or not isinstance(result, dict): + await callback.message.answer("Ошибка при продлении. Попробуй позже.") + await callback.answer() + return + + detail = result.get("detail") + if detail == "INSUFFICIENT_FUNDS": + await callback.message.answer("Недостаточно средств на балансе.") + await callback.answer() + return + if detail == "TARIFF_NOT_FOUND": + await callback.message.answer("Тариф не найден.") + await callback.answer() + return + if detail == "ACTIVE_SUBSCRIPTION_EXISTS": + await callback.message.answer("У тебя уже есть активная подписка этого типа.") + await callback.answer() + return + + # Успех — backend вернёт message с URI + uri = result.get("message") + if isinstance(uri, str) and uri: + escaped_uri = escape_markdown_v2(uri) + await callback.message.answer( + "Подписка успешно продлена.\n" + f"Твой конфиг:\n```{escaped_uri}```", + parse_mode=ParseMode.MARKDOWN_V2, + ) + else: + await callback.message.answer("Подписка успешно продлена.") + + # Перерисовать карточку (обновлённый список) + await _show_subscriptions_view( + msg=callback.message, + telegram_id=callback.from_user.id, + index=0, + edit=True, + ) + await callback.answer() + + +# ===== Меню покупки подписки (Basic / Pro / Family) ===== + @router.callback_query(lambda callback: callback.data == "buy_subscription") async def buy_subscription_callback_handler(callback: types.CallbackQuery): """ - Обработчик callback_query с data="buy_subscription". + Меню выбора тарифа: Basic / Pro / Family + показ баланса. """ - await callback.message.edit_text( - f"Ознакомься с условиями в вкладке \"О тарифах\" и выбери подходящий 🦅", - reply_markup=buy_keyboard() + user_data = await call_api("GET", f"/user/{callback.from_user.id}") + balance = user_data.get("balance", 0) if user_data else "?" + + text = ( + "➕ Подключить новую подписку\n\n" + "🐣 Lark Basic - 200 ₽ / мес\n" + "🦅 Lark Pro - 400 ₽ / мес\n" + "👨‍👩‍👧 Lark Family - 700 ₽ / мес\n\n" + f"💰 Баланс: {balance} ₽\n\n" + "Выбери тариф:" ) + await callback.message.edit_text( + text, + parse_mode=ParseMode.HTML, + reply_markup=buy_keyboard(), + ) + await callback.answer() + + @router.callback_query(lambda callback: callback.data == "subs") async def subs_callback_handler(callback: types.CallbackQuery): """ - Обработчик callback_query с data="subs". - """ + Кнопка 'Lark Basic' — раскрываем варианты сроков. + """ await callback.message.edit_text( - "Подписки птенчик", - reply_markup=tarif_Lark_keyboard() + "Тариф Lark Basic — выбери срок:", + reply_markup=tarif_Lark_keyboard(), ) + await callback.answer() + @router.callback_query(lambda callback: callback.data == "subs_pro") async def subs_pro_callback_handler(callback: types.CallbackQuery): """ - Обработчик callback_query с data="subs_pro". - """ + Кнопка 'Lark Pro'. + """ await callback.message.edit_text( - "Подписки птенчик ПРО", - reply_markup=tarif_Lark_pro_keyboard() + "Тариф Lark Pro — выбери срок:", + reply_markup=tarif_Lark_pro_keyboard(), ) + await callback.answer() + + +@router.callback_query(lambda callback: callback.data == "subs_family") +async def subs_family_callback_handler(callback: types.CallbackQuery): + """ + Кнопка 'Lark Family'. + """ + await callback.message.edit_text( + "Тариф Lark Family — выбери срок:", + reply_markup=tarif_Lark_family_keyboard(), + ) + await callback.answer() + @router.callback_query(lambda callback: callback.data.startswith("Lark:")) async def lark_tariff_callback_handler(callback: types.CallbackQuery): """ - Обработчик для выбора тарифа Lark. + Обработчик выбора конкретного тарифа Lark. + callback_data: Lark:: """ - data = callback.data.split(":") - tariff_name = data[0] - tariff_class = data[1] - tariff_time = int(data[2]) + try: + _, tariff_class, months_str = callback.data.split(":") + months = int(months_str) + except Exception: + await callback.message.answer("Некорректные данные тарифа.") + await callback.answer() + return - # Определение окончания для месяцев - if tariff_time == 1: - months = f"{tariff_time} месяц" - elif 2 <= tariff_time <= 4: - months = f"{tariff_time} месяца" + if months == 1: + months_label = "1 месяц" + elif 2 <= months <= 4: + months_label = f"{months} месяца" else: - months = f"{tariff_time} месяцев" + months_label = f"{months} месяцев" - text = f"Тариф {tariff_name} на {months}. Продолжите покупку..." - - # Рендеринг клавиатуры - keyboard = tarif_confirm_keyboard(tariff_name, tariff_time, tariff_class) + text = f"Тариф Lark {tariff_class} на {months_label}. Продолжить покупку?" + + keyboard = tarif_confirm_keyboard("Lark", months, tariff_class) await callback.message.edit_text(text=text, reply_markup=keyboard) + await callback.answer() + @router.callback_query(lambda callback: callback.data.startswith("confirm:")) async def confirm_callback_handler(callback: types.CallbackQuery): """ - Обработчик подтверждения подписки. + Подтверждение покупки тарифа. """ try: - data = callback.data.split(":")[1] - tariff_info = data.split("_") - plan_id = f"{tariff_info[0]}_{tariff_info[1]}_{tariff_info[2]}" - result = await call_api("POST", "/subscription/buy", {"telegram_id": callback.from_user.id, "plan_name": "basic"}) - detail = result.get("detail", {}) - - if detail == "ERROR": - await callback.message.edit_text("Произошла ошибка при оформлении подписки.") - elif detail == "INSUFFICIENT_FUNDS": - await callback.message.edit_text("Денег на вашем балансе не достаточно.") - elif detail == "TARIFF_NOT_FOUND": - await callback.message.edit_text("Ваш тариф не найден.") - elif detail == "ACTIVE_SUBSCRIPTION_EXISTS": - await callback.message.edit_text("Вы уже имеете активную подписку.") - else: - uri = result.get("uri", {}) - escaped_text = escape_markdown_v2(f"Подписка успешно оформлена!") - answer_text = f"Ваш конфиг для подключения: ```{uri}```" - await callback.message.edit_text(escaped_text) - await callback.message.answer(answer_text, parse_mode=ParseMode.MARKDOWN_V2) - except Exception as e: - logger.exception(f"Ошибка при обработке подтверждения подписки: {e}") - await callback.message.edit_text("Произошла ошибка при оформлении подписки.") - finally: + data = callback.data.split(":", maxsplit=1)[1] + name, classif, amount_str = data.split("_") + plan_id = f"{name}_{classif}_{amount_str}" + except Exception: + await callback.message.edit_text("Некорректные данные тарифа.") await callback.answer() + return + result = await call_api( + "POST", + "/subscription/buy", + {"telegram_id": str(callback.from_user.id), "plan_id": plan_id}, + ) + if result == "ERROR" or not isinstance(result, dict): + await callback.message.edit_text("Ошибка при оформлении подписки.") + await callback.answer() + return + + detail = result.get("detail") + + if detail == "INSUFFICIENT_FUNDS": + await callback.message.edit_text( + "Недостаточно средств. Попробуй пополнить баланс." + ) + await callback.answer() + return + if detail == "TARIFF_NOT_FOUND": + await callback.message.edit_text("Тариф не найден.") + await callback.answer() + return + if detail == "ACTIVE_SUBSCRIPTION_EXISTS": + await callback.message.edit_text("У тебя уже есть активная подписка.") + await callback.answer() + return + + uri = result.get("message", "") + if uri: + escaped_uri = escape_markdown_v2(uri) + answer_text = f"Подписка успешно оформлена!\n\nТвой конфиг:\n```{escaped_uri}```" + await callback.message.edit_text("Подписка успешно оформлена.") + await callback.message.answer( + answer_text, + parse_mode=ParseMode.MARKDOWN_V2, + ) + else: + await callback.message.edit_text("Подписка успешно оформлена.") + + await callback.answer() diff --git a/keyboard/keyboards.py b/keyboard/keyboards.py index f4f78ae..e7e9879 100644 --- a/keyboard/keyboards.py +++ b/keyboard/keyboards.py @@ -4,17 +4,26 @@ from aiogram.types import InlineKeyboardButton, KeyboardButton def main_keyboard(): """ - Главное меню (только визуал перетянут под твой стиль) + Главное меню """ builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton( - text="📜 Профиль", callback_data="profile")) - builder.row(InlineKeyboardButton(text="❔ FAQ ❔", callback_data="faq")) - # Оставляем URL как у Вовы, меняем только текст + builder.row( + InlineKeyboardButton( + text="📜 Профиль", + callback_data="profile", + ) + ) + # ------ + builder.row( + InlineKeyboardButton( + text="❔ FAQ ❔", + callback_data="faq", + ) + ) builder.row( InlineKeyboardButton( text="ℹ️ О нас", - url="https://www.youtube.com/watch?v=Zirn-CKck-c" + url="https://www.youtube.com/watch?v=Zirn-CKck-c", ) ) return builder.as_markup() @@ -22,26 +31,14 @@ def main_keyboard(): def account_keyboard(): """ - Аккаунт / профиль - Визуал — твой, callback_data — как у Вовы. + Клавиатура профиля: + пополнить баланс, история транзакций, назад в главное меню. """ builder = InlineKeyboardBuilder() builder.row( InlineKeyboardButton( text="🪙 Пополнить баланс", - callback_data="popup", - ) - ) - builder.row( - InlineKeyboardButton( - text="🦴 Мои подписки", - callback_data="buy_subscription", - ) - ) - builder.row( - InlineKeyboardButton( - text="📡 Руководство по подключению", - callback_data="guide", + callback_data="balance", ) ) builder.row( @@ -59,6 +56,90 @@ def account_keyboard(): return builder.as_markup() +def balance_keyboard(): + """ + Экран баланса + """ + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text="🪙 Пополнить баланс", + callback_data="popup", + ) + ) + builder.row( + InlineKeyboardButton( + text="🧾 История транзакций", + callback_data="tranhist", + ) + ) + builder.row( + InlineKeyboardButton( + text="🔙 Назад", + callback_data="profile", + ) + ) + return builder.as_markup() + + +def popup_keyboard(): + """ + Суммы пополнения: 200, 300, 600, 1000 ₽. + """ + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton(text="200 ₽", callback_data="popup:200"), + ) + builder.row( + InlineKeyboardButton(text="300 ₽", callback_data="popup:300"), + ) + builder.row( + InlineKeyboardButton(text="600 ₽", callback_data="popup:600"), + ) + builder.row( + InlineKeyboardButton(text="1000 ₽", callback_data="popup:1000"), + ) + builder.row( + InlineKeyboardButton( + text="🔙 Назад", + callback_data="profile", # назад в профиль + ) + ) + return builder.as_markup() + + +def payment_methods_keyboard(amount: int): + """ + Способы оплаты для выбранной суммы. + """ + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text="⭐ Telegram Stars", + callback_data=f"method_stars_{amount}", + ) + ) + builder.row( + InlineKeyboardButton( + text="💵 YooKassa", + callback_data=f"method_ykassa_{amount}", + ) + ) + builder.row( + InlineKeyboardButton( + text="🪙 CryptoBot", + callback_data=f"method_crypto_{amount}", + ) + ) + builder.row( + InlineKeyboardButton( + text="🔙 Назад", + callback_data="popup", + ) + ) + return builder.as_markup() + + def ticket_list_keyboard(tickets): builder = InlineKeyboardBuilder() for ticket in tickets: @@ -108,12 +189,11 @@ def ticket_keyboard(): def buy_keyboard(): """ Меню выбора тарифа. - Лейблы ближе к твоему стилю, но callback’и остаются старые. """ builder = InlineKeyboardBuilder() builder.row( InlineKeyboardButton( - text="🐣 Lark (Basic)", + text="🐣 Lark Basic", callback_data="subs", ) ) @@ -125,74 +205,8 @@ def buy_keyboard(): ) builder.row( InlineKeyboardButton( - text="ℹ️ О тарифах", - url="https://t.me/proxylark/19", - ) - ) - builder.row( - InlineKeyboardButton( - text="🔙 Назад", - callback_data="profile", - ) - ) - return builder.as_markup() - - -def subhist_keyboard(): - """ - Подписки — история/список - """ - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🔙 Назад", - callback_data="profile", - ) - ) - return builder.as_markup() - - -def popup_keyboard(): - """ - Пополнение (суммы, стиль как в твоём topup_menu) - """ - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="200 ₽", callback_data="popup:200"), - InlineKeyboardButton(text="500 ₽", callback_data="popup:500"), - ) - builder.row( - InlineKeyboardButton(text="1000 ₽", callback_data="popup:1000"), - InlineKeyboardButton(text="2000 ₽", callback_data="popup:2000"), - ) - builder.row( - InlineKeyboardButton(text="3000 ₽", callback_data="popup:3000"), - InlineKeyboardButton(text="5000 ₽", callback_data="popup:5000"), - ) - builder.row( - InlineKeyboardButton( - text="🔙 Назад", - callback_data="profile", - ) - ) - return builder.as_markup() - - -def balance_keyboard(): - """ - Баланс - """ - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🪙 Пополнить баланс", - callback_data="popup", - ) - ) - builder.row( - InlineKeyboardButton( - text="🧾 История транзакций", - callback_data="tranhist", + text="👨‍👩‍👧 Lark Family", + callback_data="subs_family", ) ) builder.row( @@ -206,7 +220,7 @@ def balance_keyboard(): def tarif_Lark_keyboard(): """ - Тариф Lark (Standart) — только подписи меняем. + Тариф Lark Basic (Standart) """ builder = InlineKeyboardBuilder() builder.row( @@ -268,6 +282,88 @@ def tarif_Lark_pro_keyboard(): return builder.as_markup() +def tarif_Lark_family_keyboard(): + """ + Тариф Lark Family. + """ + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text="👨‍👩‍👧 Lark Family 1 месяц", + callback_data="Lark:Family:1", + ) + ) + builder.row( + InlineKeyboardButton( + text="👨‍👩‍👧 Lark Family 6 месяцев", + callback_data="Lark:Family:6", + ) + ) + builder.row( + InlineKeyboardButton( + text="👨‍👩‍👧 Lark Family 12 месяцев", + callback_data="Lark:Family:12", + ) + ) + builder.row( + InlineKeyboardButton( + text="🔙 Назад", + callback_data="buy_subscription", + ) + ) + return builder.as_markup() + + +def subscriptions_card_keyboard(sub_id: str, index: int, total: int): + """ + Карточка подписки: + навигация, конфиг, продление, новая, назад. + """ + builder = InlineKeyboardBuilder() + + nav = [] + if index > 0: + nav.append( + InlineKeyboardButton( + text="⬅️", + callback_data=f"sub_prev:{index-1}", + ) + ) + if index < total - 1: + nav.append( + InlineKeyboardButton( + text="➡️", + callback_data=f"sub_next:{index+1}", + ) + ) + if nav: + builder.row(*nav) + + builder.row( + InlineKeyboardButton( + text="🔑 Конфиг", + callback_data=f"sub_cfg:{sub_id}", + ), + InlineKeyboardButton( + text="🔁 Продлить", + callback_data=f"sub_renew:{sub_id}", + ), + ) + builder.row( + InlineKeyboardButton( + text="➕ Новая", + callback_data="buy_subscription", + ) + ) + builder.row( + InlineKeyboardButton( + text="🔙 Назад", + callback_data="profile", + ) + ) + return builder.as_markup() + + def guide_keyboard(): """ Руководство по подключению @@ -322,7 +418,7 @@ def tranhist_keyboard(): return builder.as_markup() -def tarif_confirm_keyboard(name, amount, classif): +def tarif_confirm_keyboard(name: str, amount: int, classif: str): """ Подтверждение покупки тарифа """ @@ -344,7 +440,7 @@ def tarif_confirm_keyboard(name, amount, classif): def confirm_popup_keyboard(): """ - Подтверждение пополнения — без «иди нахуй», мы же под босса господина ларк это красим.... + Подтверждение пополнения. """ builder = InlineKeyboardBuilder() builder.row( diff --git a/main.py b/main.py index 04735c9..1b3e2b4 100644 --- a/main.py +++ b/main.py @@ -11,7 +11,8 @@ import logging BOT_TOKEN = os.getenv("TOKEN") if not BOT_TOKEN: - raise ValueError("Не задан токен бота. Убедитесь, что переменная окружения 'TOKEN' установлена.") + raise ValueError( + "Не задан токен бота. Убедитесь, что переменная окружения 'TOKEN' установлена.") bot = Bot(token=BOT_TOKEN) dp = Dispatcher() @@ -23,9 +24,10 @@ dp.message.middleware(AntiSpamMiddleware(rate_limit=1)) async def set_commands(): """Устанавливает команды для бота.""" commands = [ - BotCommand(command="/start", description="🥚Главное меню"), - BotCommand(command="/subscriptions", description="🦴Мои подписки"), - BotCommand(command="/support", description="❕Поддержка❕"), + BotCommand(command="start", description="Запуск бота"), + BotCommand(command="subscriptions", description="Мои подписки"), + BotCommand(command="support", description="Техподдержка"), + BotCommand(command="referrals", description="Реферальная программа"), ] await bot.set_my_commands(commands)