From a572c901941d2f0bb42cfb70d7188212c971ac61 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 25 Nov 2025 19:18:22 +0300 Subject: [PATCH] =?UTF-8?q?UI:=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BE=D0=BA?= =?UTF-8?q?=20=D0=B8=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D1=84=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/subscriptions.py | 509 ++++++++++++++++++++++++++++++-------- keyboard/keyboards.py | 160 ++++++++++-- 2 files changed, 542 insertions(+), 127 deletions(-) diff --git a/handlers/subscriptions.py b/handlers/subscriptions.py index 246c43d..da28b62 100644 --- a/handlers/subscriptions.py +++ b/handlers/subscriptions.py @@ -1,102 +1,376 @@ 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 +import logging +from datetime import datetime +import aiohttp + +from instences.config import BASE_URL_FASTAPI from keyboard.keyboards import ( - tarif_Lark_pro_keyboard, + buy_keyboard, tarif_Lark_keyboard, + tarif_Lark_pro_keyboard, tarif_Lark_family_keyboard, tarif_confirm_keyboard, - buy_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 -@router.message(Command("subscriptions")) -async def supp(message: types.Message): +def _plan_human_title(plan: str) -> str: """ - Меню системы подписок + Красивое имя тарифа по plan-строке из БД. """ - text = "" - uri = None # Инициализация переменной - try: - # Вызов API для получения URI - result = await call_api("GET", f"/uri?telegram_id={message.from_user.id}") - # Получаем URI из ответа или "Error", если ключ отсутствует - uri = result.get('detail', "Error") + 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 - # Проверка результата - if uri == "Error": - text = escape_markdown_v2("Произошла ошибка при получении URI") - elif uri == "SUB_ERROR": - text = escape_markdown_v2("Вы ещё не приобрели подписки!!") - elif "vless" in uri: - escaped_uri = escape_markdown_v2(uri) # Экранирование URI - text = f"Ваша подписка: ```{escaped_uri}```" + +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: - text = escape_markdown_v2("Произошла ошибка при обработке URI") - except Exception as e: - # Логирование ошибок - logger.error(f"Ошибка при вызове API для подписки: {e}") - text = escape_markdown_v2( - "Произошла неожиданная ошибка при получении подписки.") + await msg.answer(text) + return - # Ответ пользователю - await message.answer( - text, - parse_mode=ParseMode.MARKDOWN_V2 + 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 cmd_subscriptions(message: types.Message): + """ + /subscriptions — открыть карточку подписок. + """ + 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): """ Меню выбора тарифа: Basic / Pro / Family + показ баланса. """ - # Тянем пользователя, чтобы показать баланс user_data = await call_api("GET", f"/user/{callback.from_user.id}") balance = user_data.get("balance", 0) if user_data else "?" @@ -120,32 +394,34 @@ async def buy_subscription_callback_handler(callback: types.CallbackQuery): @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): """ - Обработчик callback_query с data="subs_family". + Кнопка 'Lark Family'. """ await callback.message.edit_text( - "Подписки Lark Family", + "Тариф Lark Family — выбери срок:", reply_markup=tarif_Lark_family_keyboard(), ) await callback.answer() @@ -154,56 +430,83 @@ async def subs_family_callback_handler(callback: types.CallbackQuery): @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}. Продолжите покупку..." + text = f"Тариф Lark {tariff_class} на {months_label}. Продолжить покупку?" - # Рендеринг клавиатуры - keyboard = tarif_confirm_keyboard(tariff_name, tariff_time, tariff_class) + 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_id": plan_id}) - 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("message", {}) - 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 9af09ba..f5afcc4 100644 --- a/keyboard/keyboards.py +++ b/keyboard/keyboards.py @@ -1,3 +1,5 @@ +from aiogram.types import InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder from aiogram.types import InlineKeyboardButton, KeyboardButton @@ -121,11 +123,136 @@ def buy_keyboard(): return builder.as_markup() -def subhist_keyboard(): +def tarif_Lark_keyboard(): + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text="🐣 Lark 1 месяц", + callback_data="Lark:Standart:1", + ) + ) + builder.row( + InlineKeyboardButton( + text="🐣 Lark 6 месяцев", + callback_data="Lark:Standart:6", + ) + ) + builder.row( + InlineKeyboardButton( + text="🐣 Lark 12 месяцев", + callback_data="Lark:Standart:12", + ) + ) + builder.row( + InlineKeyboardButton( + text="🔙 Назад", + callback_data="buy_subscription", + ) + ) + return builder.as_markup() + + +def tarif_Lark_pro_keyboard(): + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text="🦅 Lark Pro 1 месяц", + callback_data="Lark:Pro:1", + ) + ) + builder.row( + InlineKeyboardButton( + text="🦅 Lark Pro 6 месяцев", + callback_data="Lark:Pro:6", + ) + ) + builder.row( + InlineKeyboardButton( + text="🦅 Lark Pro 12 месяцев", + callback_data="Lark:Pro:12", + ) + ) + builder.row( + InlineKeyboardButton( + text="🔙 Назад", + callback_data="buy_subscription", + ) + ) + return builder.as_markup() + + +def tarif_Lark_family_keyboard(): + 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="🔙 Назад", @@ -135,34 +262,18 @@ def subhist_keyboard(): return builder.as_markup() -def payment_methods_keyboard(amount: int): - """ - Способы оплаты для выбранной суммы. - ЛаркинсКоины убрал нахер - """ +def tarif_confirm_keyboard(name: str, amount: int, classif: str): builder = InlineKeyboardBuilder() builder.row( InlineKeyboardButton( - text="⭐ Telegram Stars", - callback_data=f"method_stars_{amount}", + text="✅ Подтвердить", + callback_data=f"confirm:{name}_{classif}_{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", + text="🔙 Отменить", + callback_data="buy_subscription", ) ) return builder.as_markup() @@ -340,7 +451,8 @@ def tarif_confirm_keyboard(name, amount, classif): def confirm_popup_keyboard(): """ - Подтверждение пополнения — без «иди нахуй», мы же под босса господина ларк это красим.... + аааааааааааааааааааааа + """ builder = InlineKeyboardBuilder() builder.row(