from aiogram import Router, types 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 ( 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: str, endpoint: str, data: dict | None = None): """ Унифицированный вызов backend API. """ url = f"{BASE_URL_FASTAPI}{endpoint}" logger.info(f"API {method} {url} data={data}") try: async with aiohttp.ClientSession() as session: 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"API exception {url}: {e}") return "ERROR" def escape_markdown_v2(text: str) -> str: """ Экранирует спецсимволы для Markdown_V2. """ 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): user = await call_api("GET", f"/user/{telegram_id}") if not isinstance(user, dict): return None, [] user_id = user.get("id") or user.get("telegram_id") or user.get("user_id") if not user_id: return None, [] 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_name")) end_raw = sub.get("end_date") # Исправил date_str = "—" days_left = None status_emoji = "🟢" if end_raw: try: dt = datetime.fromisoformat(end_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 uri = None if isinstance(detail, str): uri = detail.strip() elif isinstance(detail, list) and detail: uri = str(detail[0]).strip() if not uri: await callback.message.answer( "Что-то пошло не так при выдаче конфига." ) await callback.answer() return await callback.message.answer( f"Твой конфиг:\n{uri}", parse_mode=ParseMode.HTML, ) await callback.answer() @router.callback_query(lambda c: c.data.startswith("sub_renew:")) async def cb_sub_renew(callback: types.CallbackQuery): 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_name = target.get("plan_name") if not plan_name: await callback.message.answer("Не удалось определить тариф для продления.") await callback.answer() return result = await call_api( "POST", "/subscription/buy", {"telegram_id": str(callback.from_user.id), "plan_name": plan_name}, ) 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 uri = result.get("message") if isinstance(uri, str) and uri: await callback.message.answer( "Подписка успешно продлена.\n" f"Твой конфиг:\n{uri}", parse_mode=ParseMode.HTML, ) 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 "?" 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): """ Кнопка 'Lark Basic' — раскрываем варианты сроков. """ await callback.message.edit_text( "Тариф 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): """ Кнопка 'Lark Pro'. """ await callback.message.edit_text( "Тариф 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. callback_data: Lark:: """ try: _, tariff_class, months_str = callback.data.split(":") months = int(months_str) except Exception: await callback.message.answer("Некорректные данные тарифа.") await callback.answer() return if months == 1: months_label = "1 месяц" elif 2 <= months <= 4: months_label = f"{months} месяца" else: months_label = f"{months} месяцев" 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(":", maxsplit=1)[1] name, classif, amount_str = data.split("_") plan_name = 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_name": plan_name}, ) 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: await callback.message.edit_text("Подписка успешно оформлена.") await callback.message.answer( f"Подписка успешно оформлена!\n\nТвой конфиг:\n{uri}", parse_mode=ParseMode.HTML, ) else: await callback.message.edit_text("Подписка успешно оформлена.") await callback.answer()