diff --git a/config.py b/config.py index 03e0bcf..6097649 100644 --- a/config.py +++ b/config.py @@ -11,6 +11,7 @@ CONFIG = { "DATABASE_URL": os.getenv("DATABASE_URL"), "ADMIN_IDS": [int(i.strip()) for i in os.getenv("ADMIN_IDS", "").split(",") if i.strip()], "PROVIDER_TOKEN": os.getenv("PROVIDER_TOKEN", ""), + "BASE_URL": os.getenv("BASE_URL"), # Внешний домен (например, https://vpn.example.com) } PLANS = { diff --git a/main.py b/main.py index 61539be..a7f7109 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,8 @@ from typing import Optional, Dict, Any import json import random import string +from qrcode import QRCode +import io from aiogram import Bot, Dispatcher, Router, F from aiogram.filters import Command, StateFilter @@ -13,7 +15,8 @@ from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.storage.memory import MemoryStorage from aiogram.types import ( Message, CallbackQuery, InlineKeyboardMarkup, - InlineKeyboardButton, LabeledPrice, PreCheckoutQuery + InlineKeyboardButton, LabeledPrice, PreCheckoutQuery, + BufferedInputFile ) import aiohttp import aiosqlite @@ -67,26 +70,36 @@ class MarzbanAPI: await self.login() headers = {"Authorization": f"Bearer {self.token}"} + url = f"{self.url}/api{endpoint}" + + logger.debug(f"Marzban Request: {method} {url} Payload: {kwargs.get('json')}") + async with self.session.request( - method, f"{self.url}/api{endpoint}", headers=headers, **kwargs + method, url, headers=headers, **kwargs ) as resp: + data = await resp.json() + logger.info(f"Marzban Response [{resp.status}]: {data}") + if resp.status == 401: await self.login() headers = {"Authorization": f"Bearer {self.token}"} async with self.session.request( - method, f"{self.url}/api{endpoint}", headers=headers, **kwargs + method, url, headers=headers, **kwargs ) as retry_resp: - return await retry_resp.json() - return await resp.json() + retry_data = await retry_resp.json() + logger.info(f"Marzban Retry Response [{retry_resp.status}]: {retry_data}") + return retry_data + return data async def create_user(self, username: str, data_limit: int, expire_days: int): expire_timestamp = int((datetime.now() + timedelta(days=expire_days)).timestamp()) payload = { "username": username, "proxies": { - "vless": {}, - "vmess": {} - }, + "vless": {} + }, + "inbounds": {}, # Разрешить все входящие + "excluded_inbounds": {}, # Ничего не исключать "data_limit": data_limit * 1024 * 1024 * 1024, # GB to bytes "expire": expire_timestamp, "status": "active" @@ -101,6 +114,7 @@ class MarzbanAPI: payload = { "data_limit": data_limit * 1024 * 1024 * 1024, "expire": expire_timestamp, + "excluded_inbounds": {}, # Снимаем ограничения при продлении "status": "active" } return await self._request("PUT", f"/user/{username}", json=payload) @@ -267,18 +281,17 @@ class Database: code, discount, uses, created_by ) - async def use_promo_code(self, code: str): - promo = await self.fetchrow( + async def get_promo_code(self, code: str): + return await self.fetchrow( "SELECT * FROM promo_codes WHERE code = $1 AND uses_left > 0", code ) - if not promo: - return None + + async def decrement_promo_usage(self, code: str): await self.execute( "UPDATE promo_codes SET uses_left = uses_left - 1 WHERE code = $1", code ) - return promo['discount'] async def add_payment(self, user_id: int, plan: str, amount: int, promo_code: str = None): await self.execute( @@ -346,6 +359,13 @@ async def cmd_start(message: Message, state: FSMContext): user_id = message.from_user.id user = await db.get_user(user_id) + # Автоматическая регистрация админа + if not user and user_id in CONFIG["ADMIN_IDS"]: + marzban_username = f"user_{user_id}" + await db.create_user(user_id, message.from_user.username, marzban_username, invited_by=None) + user = await db.get_user(user_id) + await message.answer("✅ Администратор автоматически зарегистрирован.") + if user: is_admin = user_id in CONFIG["ADMIN_IDS"] await message.answer( @@ -385,7 +405,15 @@ async def process_invite_code(message: Message, state: FSMContext): async def show_subscription(callback: CallbackQuery): user = await db.get_user(callback.from_user.id) - if not user['subscription_until'] or user['subscription_until'] < datetime.now(): + sub_until = user['subscription_until'] + if isinstance(sub_until, str): + try: + sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S') + except ValueError: + # Try with microseconds if previous format fails + sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S.%f') + + if not sub_until or sub_until < datetime.now(): await callback.message.edit_text( "❌ У вас нет активной подписки.\n\n" "Приобретите подписку, чтобы начать пользоваться VPN.", @@ -395,56 +423,121 @@ async def show_subscription(callback: CallbackQuery): try: marzban_user = await marzban.get_user(user['marzban_username']) - used_traffic = marzban_user.get('used_traffic', 0) / (1024**3) # bytes to GB + # Если пользователя нет в панели, но подписка активна - пробуем создать заново + if isinstance(marzban_user, dict) and marzban_user.get('detail') == 'User not found': + logger.warning(f"User {user['marzban_username']} not found in Marzban, restoring...") + await marzban.create_user( + user['marzban_username'], + user['data_limit'] or 50, # Лимит из базы или 50 по умолчанию + 30 # Срок не важен, он обновится при следующей оплате + ) + marzban_user = await marzban.get_user(user['marzban_username']) + + used_traffic = marzban_user.get('used_traffic', 0) / (1024**3) + + sub_url = marzban_user.get('subscription_url', 'Генерируется...') + if sub_url and sub_url.startswith('/'): + # Используем BASE_URL если он есть, иначе MARZBAN_URL + base = CONFIG.get('BASE_URL') or CONFIG['MARZBAN_URL'] + sub_url = f"{base.rstrip('/')}{sub_url}" + info_text = ( f"📊 Ваша подписка:\n\n" - f"⏰ Действует до: {user['subscription_until'].strftime('%d.%m.%Y %H:%M')}\n" + f"⏰ Действует до: {sub_until.strftime('%d.%m.%Y %H:%M')}\n" f"📦 Лимит трафика: {user['data_limit']} ГБ\n" f"📊 Использовано: {used_traffic:.2f} ГБ\n\n" - f"🔗 Конфигурация:\n" - f"`{marzban_user.get('subscription_url', 'Генерируется...')}`" + f"🎫 Ссылка на подписку (рекомендуется):\n" + f"`{sub_url}`" ) except Exception as e: logger.error(f"Error getting user info: {e}") info_text = "⚠️ Ошибка получения данных. Попробуйте позже." - await callback.message.edit_text( - info_text, - reply_markup=main_keyboard(callback.from_user.id in CONFIG["ADMIN_IDS"]), - parse_mode="Markdown" - ) + try: + # Генерация QR-кода + qr = QRCode(version=1, box_size=10, border=5) + qr.add_data(sub_url) + qr.make(fit=True) + qr_img = qr.make_image(fill_color="black", back_color="white") + + # Сохранение в буфер + img_buffer = io.BytesIO() + qr_img.save(img_buffer) + img_buffer.seek(0) + qr_file = BufferedInputFile(img_buffer.getvalue(), filename="subscription_qr.png") + + # Если это текстовое сообщение - удаляем и шлем фото + # Если это уже фото - пробуем edit_media (но проще удалить и прислать новое для чистоты) + await callback.message.delete() + await callback.message.answer_photo( + photo=qr_file, + caption=info_text, + reply_markup=main_keyboard(callback.from_user.id in CONFIG["ADMIN_IDS"]), + parse_mode="Markdown" + ) + except Exception as e: + logger.error(f"Error sending subscription photo: {e}") + # В случае ошибки - шлем текст как раньше + await callback.message.answer(info_text, reply_markup=main_keyboard(callback.from_user.id in CONFIG["ADMIN_IDS"]), parse_mode="Markdown") @router.callback_query(F.data == "buy_subscription") async def show_plans(callback: CallbackQuery): - await callback.message.edit_text( + text = ( "💎 Выберите тарифный план:\n\n" "Все планы включают:\n" "• Безлимитная скорость\n" "• Поддержка всех устройств\n" - "• Техподдержка 24/7", - reply_markup=plans_keyboard() + "• Техподдержка 24/7" ) + kb = plans_keyboard() + + if callback.message.photo: + await callback.message.delete() + await callback.message.answer(text, reply_markup=kb) + else: + await callback.message.edit_text(text, reply_markup=kb) @router.callback_query(F.data.startswith("plan_")) async def process_plan_selection(callback: CallbackQuery, state: FSMContext): - plan_id = callback.data.split("_")[1] + plan_id = callback.data.replace("plan_", "", 1) plan = PLANS[plan_id] await state.update_data(selected_plan=plan_id) + data = await state.get_data() - await callback.message.edit_text( - f"Вы выбрали: {plan['name']}\n" - f"Стоимость: {plan['price']} ⭐\n\n" - f"📦 Трафик: {plan['data_limit']} ГБ\n" - f"⏰ Период: {plan['days']} дней\n\n" - "Есть промокод на скидку?", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="✅ Ввести промокод", callback_data="enter_promo")], - [InlineKeyboardButton(text="💳 Оплатить", callback_data="pay_now")], - [InlineKeyboardButton(text="◀️ Назад", callback_data="buy_subscription")], - ]) - ) + # Если промокод уже активирован + if 'promo_code' in data and 'discount' in data: + discount = data['discount'] + new_price = int(plan['price'] * (100 - discount) / 100) + await state.update_data(final_price=new_price) + + await callback.message.edit_text( + f"Вы выбрали: {plan['name']}\n" + f"Цена без скидки: {plan['price']} ⭐\n" + f"✅ Применен промокод: {discount}%\n" + f"Итого к оплате: {new_price} ⭐\n\n" + f"📦 Трафик: {plan['data_limit']} ГБ\n" + f"⏰ Период: {plan['days']} дней", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="💳 Оплатить", callback_data="pay_now")], + [InlineKeyboardButton(text="❌ Сбросить промокод", callback_data="reset_promo")], + [InlineKeyboardButton(text="◀️ Назад", callback_data="buy_subscription")], + ]) + ) + else: + await callback.message.edit_text( + f"Вы выбрали: {plan['name']}\n" + f"Стоимость: {plan['price']} ⭐\n\n" + f"📦 Трафик: {plan['data_limit']} ГБ\n" + f"⏰ Период: {plan['days']} дней\n\n" + "Есть промокод на скидку?", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="✅ Ввести промокод", callback_data="enter_promo")], + [InlineKeyboardButton(text="💳 Оплатить", callback_data="pay_now")], + [InlineKeyboardButton(text="◀️ Назад", callback_data="buy_subscription")], + ]) + ) @router.callback_query(F.data == "enter_promo") async def ask_promo(callback: CallbackQuery, state: FSMContext): @@ -454,39 +547,140 @@ async def ask_promo(callback: CallbackQuery, state: FSMContext): @router.message(PromoStates.waiting_for_promo) async def process_promo(message: Message, state: FSMContext): promo_code = message.text.strip().upper() - discount = await db.use_promo_code(promo_code) + promo = await db.get_promo_code(promo_code) - data = await state.get_data() - plan_id = data['selected_plan'] - plan = PLANS[plan_id] - - if discount: - new_price = int(plan['price'] * (100 - discount) / 100) - await state.update_data(promo_code=promo_code, final_price=new_price) - await message.answer( - f"✅ Промокод применен! Скидка: {discount}%\n" - f"Новая цена: {new_price} ⭐", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="💳 Оплатить", callback_data="pay_now")], - [InlineKeyboardButton(text="◀️ Назад", callback_data="buy_subscription")], - ]) - ) + if promo: + discount = promo['discount'] + await state.update_data(promo_code=promo_code, discount=discount) + + data = await state.get_data() + + # Если план уже выбран (переход из покупки) + if 'selected_plan' in data: + plan_id = data['selected_plan'] + plan = PLANS[plan_id] + new_price = int(plan['price'] * (100 - discount) / 100) + await state.update_data(final_price=new_price) + + await message.answer( + f"✅ Промокод применен! Скидка: {discount}%\n" + f"Новая цена: {new_price} ⭐", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="💳 Оплатить", callback_data="pay_now")], + [InlineKeyboardButton(text="◀️ Назад", callback_data="buy_subscription")], + ]) + ) + # Если план не выбран (переход из главного меню) + else: + await message.answer( + f"✅ Промокод активирован! Скидка: {discount}%\n" + "Теперь выберите тариф:", + reply_markup=plans_keyboard() + ) else: await message.answer( "❌ Промокод недействителен или исчерпан.", reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="enter_promo")], - [InlineKeyboardButton(text="💳 Оплатить без промокода", callback_data="pay_now")], + [InlineKeyboardButton(text="◀️ Отмена", callback_data="back_to_main")], ]) ) await state.set_state(None) +@router.callback_query(F.data == "reset_promo") +async def reset_promo(callback: CallbackQuery, state: FSMContext): + await state.update_data(promo_code=None, discount=None, final_price=None) + # Перезагружаем выбор плана + data = await state.get_data() + if 'selected_plan' in data: + # Имитируем повторный выбор плана для обновления текста + callback.data = f"plan_{data['selected_plan']}" + await process_plan_selection(callback, state) + else: + await back_to_main(callback) + +async def grant_subscription(user_id: int, plan_id: str, promo_code: str, amount: int): + plan = PLANS[plan_id] + user = await db.get_user(user_id) + + sub_until = user['subscription_until'] + if isinstance(sub_until, str): + try: + sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S') + except ValueError: + sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S.%f') + + # Marzban + try: + marzban_username = user['marzban_username'] + resp = None + + if sub_until and sub_until > datetime.now(): + logger.info(f"Attempting to modify existing user: {marzban_username}") + resp = await marzban.modify_user( + marzban_username, + plan['data_limit'], + plan['days'] + ) + + # Если пользователь не найден в Marzban (хотя в БД бота он есть) + if isinstance(resp, dict) and resp.get('detail') == 'User not found': + logger.info(f"User {marzban_username} missing in Marzban, re-creating...") + resp = await marzban.create_user( + marzban_username, + plan['data_limit'], + plan['days'] + ) + else: + logger.info(f"Creating/Reactivating user: {marzban_username}") + resp = await marzban.create_user( + marzban_username, + plan['data_limit'], + plan['days'] + ) + except Exception as e: + logger.error(f"Marzban error in grant_subscription: {e}") + + # DB + await db.update_subscription(user_id, plan['days'], plan['data_limit']) + await db.add_payment( + user_id, + plan_id, + amount, + promo_code + ) + + if promo_code: + await db.decrement_promo_usage(promo_code) + + return plan + @router.callback_query(F.data == "pay_now") async def process_payment(callback: CallbackQuery, state: FSMContext): data = await state.get_data() - plan_id = data['selected_plan'] + plan_id = data.get('selected_plan') + + if not plan_id: + await callback.answer("❌ Сессия истекла. Пожалуйста, выберите тариф снова.", show_alert=True) + await show_plans(callback) + return + plan = PLANS[plan_id] - final_price = data.get('final_price', plan['price']) + final_price = int(data.get('final_price', plan['price'])) + promo_code = data.get('promo_code') + + if final_price <= 0: + await grant_subscription(callback.from_user.id, plan_id, promo_code, 0) + await callback.message.edit_text( + f"✅ Подписка активирована бесплатно!\n\n" + f"План: {plan['name']}\n" + f"Срок: {plan['days']} дней\n" + f"Трафик: {plan['data_limit']} ГБ\n\n" + f"Настройте подключение в меню: 📊 Моя подписка", + reply_markup=main_keyboard(callback.from_user.id in CONFIG["ADMIN_IDS"]) + ) + await state.clear() + return # Создаем инвойс для Telegram Stars await callback.message.answer_invoice( @@ -508,36 +702,14 @@ async def pre_checkout_handler(pre_checkout_query: PreCheckoutQuery): async def successful_payment(message: Message): payment = message.successful_payment plan_id, promo_code = payment.invoice_payload.split(":") - plan = PLANS[plan_id] - - user = await db.get_user(message.from_user.id) - - # Обновляем или создаем пользователя в Marzban - try: - if user['subscription_until'] and user['subscription_until'] > datetime.now(): - # Продлеваем существующую подписку - await marzban.modify_user( - user['marzban_username'], - plan['data_limit'], - plan['days'] - ) - else: - # Создаем новую подписку - await marzban.create_user( - user['marzban_username'], - plan['data_limit'], - plan['days'] - ) - except Exception as e: - logger.error(f"Marzban error: {e}") - - # Обновляем БД - await db.update_subscription(message.from_user.id, plan['days'], plan['data_limit']) - await db.add_payment( + if not promo_code: + promo_code = None + + plan = await grant_subscription( message.from_user.id, plan_id, - payment.total_amount, - promo_code if promo_code else None + promo_code, + payment.total_amount ) await message.answer( @@ -555,10 +727,14 @@ async def admin_panel(callback: CallbackQuery): await callback.answer("❌ Доступ запрещен", show_alert=True) return - await callback.message.edit_text( - "👑 Панель администратора", - reply_markup=admin_keyboard() - ) + text = "👑 Панель администратора" + kb = admin_keyboard() + + if callback.message.photo: + await callback.message.delete() + await callback.message.answer(text, reply_markup=kb) + else: + await callback.message.edit_text(text, reply_markup=kb) @router.callback_query(F.data == "admin_stats") async def admin_stats(callback: CallbackQuery): @@ -678,18 +854,28 @@ async def admin_promo_uses(message: Message, state: FSMContext): @router.callback_query(F.data == "back_to_main") async def back_to_main(callback: CallbackQuery): is_admin = callback.from_user.id in CONFIG["ADMIN_IDS"] - await callback.message.edit_text( - "Главное меню:", - reply_markup=main_keyboard(is_admin) - ) + text = "Главное меню:" + kb = main_keyboard(is_admin) + + if callback.message.photo: + await callback.message.delete() + await callback.message.answer(text, reply_markup=kb) + else: + await callback.message.edit_text(text, reply_markup=kb) @router.callback_query(F.data == "use_promo") -async def use_promo_callback(callback: CallbackQuery): - await callback.message.edit_text( - "Эта функция доступна при покупке подписки.\n" - "Сначала выберите тариф, затем можно будет ввести промокод.", - reply_markup=main_keyboard(callback.from_user.id in CONFIG["ADMIN_IDS"]) - ) +async def use_promo_callback(callback: CallbackQuery, state: FSMContext): + text = "Введите промокод для активации скидки:" + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="◀️ Отмена", callback_data="back_to_main")] + ]) + + if callback.message.photo: + await callback.message.delete() + await callback.message.answer(text, reply_markup=kb) + else: + await callback.message.edit_text(text, reply_markup=kb) + await state.set_state(PromoStates.waiting_for_promo) @router.callback_query(F.data == "help") async def help_handler(callback: CallbackQuery): @@ -706,14 +892,17 @@ async def help_handler(callback: CallbackQuery): "• Windows: v2rayN, Nekoray\n" "• macOS: V2rayU, ClashX\n\n" "❓ Возникли проблемы?\n" - "Напишите администратору: @your_admin" - ) - await callback.message.edit_text( - help_text, - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="◀️ Назад", callback_data="back_to_main")] - ]) + "Напишите администратору: @hoshimach1" ) + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="◀️ Назад", callback_data="back_to_main")] + ]) + + if callback.message.photo: + await callback.message.delete() + await callback.message.answer(help_text, reply_markup=kb) + else: + await callback.message.edit_text(help_text, reply_markup=kb) @router.callback_query(F.data == "admin_broadcast") async def admin_broadcast_start(callback: CallbackQuery, state: FSMContext): @@ -760,7 +949,7 @@ async def admin_broadcast_send(message: Message, state: FSMContext): await state.clear() # Команда для получения своего ID -@router.message(Command("myid")) +@router.message(Command("myid"), StateFilter("*")) async def cmd_myid(message: Message): await message.answer(f"Ваш Telegram ID: `{message.from_user.id}`", parse_mode="Markdown")