478 lines
21 KiB
Python
478 lines
21 KiB
Python
from aiogram import Router, F
|
||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton, BufferedInputFile
|
||
from aiogram.fsm.context import FSMContext
|
||
from aiogram.filters import Command, StateFilter, CommandStart, CommandObject
|
||
from datetime import datetime
|
||
import qrcode
|
||
from qrcode import QRCode
|
||
import io
|
||
import logging
|
||
|
||
from config import CONFIG, PLANS
|
||
from database import db
|
||
from marzban import marzban
|
||
from states import InviteStates, PromoStates
|
||
from keyboards import main_keyboard, plans_keyboard
|
||
|
||
router = Router()
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Helper to check if sub is active
|
||
def is_active(user):
|
||
if not user:
|
||
return False
|
||
sub_until = user['subscription_until']
|
||
if not sub_until:
|
||
return False
|
||
if isinstance(sub_until, str):
|
||
try:
|
||
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S')
|
||
except:
|
||
try:
|
||
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S.%f')
|
||
except:
|
||
return False # Parse error
|
||
|
||
return sub_until > datetime.now()
|
||
|
||
@router.message(CommandStart(deep_link=True))
|
||
async def cmd_start_deep_link(message: Message, command: CommandObject, state: FSMContext):
|
||
# Если запуск по ссылке (инвайт код)
|
||
code = command.args
|
||
invite = await db.check_invite_code(code)
|
||
|
||
if invite:
|
||
user_id = message.from_user.id
|
||
# Проверяем, не зарегистрирован ли уже
|
||
existing_user = await db.get_user(user_id)
|
||
if existing_user:
|
||
await message.answer("Вы уже зарегистрированы! Инвайт-код не нужен.")
|
||
await show_main_menu(message, user_id)
|
||
return
|
||
|
||
username = message.from_user.username or f"user_{user_id}"
|
||
marzban_username = message.from_user.username.lower() if message.from_user.username else f"user_{user_id}"
|
||
|
||
await db.create_user(user_id, username, marzban_username, invite['created_by'])
|
||
await db.use_invite_code(code, user_id)
|
||
|
||
await message.answer("✅ Инвайт-код принят! Добро пожаловать.")
|
||
await show_main_menu(message, user_id)
|
||
else:
|
||
await message.answer("❌ Неверный или использованный инвайт-код.")
|
||
# Fallback to normal start logic check
|
||
await cmd_start(message, state)
|
||
|
||
@router.message(Command("start"))
|
||
async def cmd_start(message: Message, state: FSMContext):
|
||
user_id = message.from_user.id
|
||
user = await db.get_user(user_id)
|
||
|
||
# Авторегистрация админа
|
||
if user_id in CONFIG["ADMIN_IDS"] and not user:
|
||
username = message.from_user.username or f"user_{user_id}"
|
||
marzban_username = message.from_user.username.lower() if message.from_user.username else f"user_{user_id}"
|
||
await db.create_user(user_id, username, marzban_username)
|
||
user = await db.get_user(user_id)
|
||
|
||
if user:
|
||
await show_main_menu(message, user_id)
|
||
else:
|
||
await message.answer(
|
||
"👋 Добро пожаловать!\n\n"
|
||
"Для использования бота необходим инвайт-код.\n"
|
||
"Если у вас есть ссылка-приглашение, перейдите по ней.\n"
|
||
"Или введите ваш инвайт-код вручную:"
|
||
)
|
||
await state.set_state(InviteStates.waiting_for_code)
|
||
|
||
@router.message(Command("myid"), StateFilter("*"))
|
||
async def cmd_myid(message: Message):
|
||
username = f"@{message.from_user.username}" if message.from_user.username else "No username"
|
||
await message.answer(
|
||
f"👤 Ваш профиль:\n"
|
||
f"ID: `{message.from_user.id}`\n"
|
||
f"User: {username}",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
async def show_main_menu(message: Message, user_id: int):
|
||
user = await db.get_user(user_id)
|
||
is_admin = user_id in CONFIG["ADMIN_IDS"]
|
||
active = is_active(user)
|
||
|
||
await message.answer(
|
||
f"Привет, {message.from_user.first_name}! 👋\n\n"
|
||
"Главное меню:",
|
||
reply_markup=main_keyboard(is_admin, active)
|
||
)
|
||
|
||
@router.message(InviteStates.waiting_for_code)
|
||
async def process_invite_code(message: Message, state: FSMContext):
|
||
code = message.text.strip().upper()
|
||
invite = await db.check_invite_code(code)
|
||
|
||
if invite:
|
||
user_id = message.from_user.id
|
||
username = message.from_user.username or f"user_{user_id}"
|
||
marzban_username = message.from_user.username.lower() if message.from_user.username else f"user_{user_id}"
|
||
|
||
await db.create_user(user_id, username, marzban_username, invite['created_by'])
|
||
await db.use_invite_code(code, user_id)
|
||
|
||
await message.answer("✅ Инвайт-код принят! Добро пожаловать.")
|
||
await show_main_menu(message, user_id)
|
||
await state.clear()
|
||
else:
|
||
await message.answer("❌ Неверный или использованный инвайт-код. Попробуйте еще раз:")
|
||
|
||
@router.callback_query(F.data == "my_subscription")
|
||
async def show_subscription(callback: CallbackQuery):
|
||
user = await db.get_user(callback.from_user.id)
|
||
if not user:
|
||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||
return
|
||
|
||
sub_until = user['subscription_until']
|
||
if not is_active(user):
|
||
await callback.answer("❌ Подписка не активна. Купите подписку в главном меню.", show_alert=True)
|
||
return
|
||
|
||
# Handle datetime conversion
|
||
if isinstance(sub_until, str):
|
||
try:
|
||
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S')
|
||
except ValueError:
|
||
try:
|
||
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S.%f')
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
marzban_user = await marzban.get_user(user['marzban_username'])
|
||
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...")
|
||
note = f"@{user['username']}" if user.get('username') else ""
|
||
# Restore with 30 days default or fetch from DB? Using 30 as per previous code
|
||
await marzban.create_user(user['marzban_username'], user['data_limit'] or 50, 30, note)
|
||
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 = CONFIG.get('BASE_URL') or CONFIG['MARZBAN_URL']
|
||
sub_url = f"{base.rstrip('/')}{sub_url}"
|
||
|
||
# Check if unlimited (far future date)
|
||
if sub_until.year > 2090:
|
||
date_str = "♾ Бессрочно"
|
||
else:
|
||
date_str = sub_until.strftime('%d.%m.%Y %H:%M')
|
||
|
||
data_limit_gb = user['data_limit']
|
||
if data_limit_gb > 10000: # Assuming huge number is unlimited in our DB logic
|
||
limit_str = "♾ Безлимит"
|
||
else:
|
||
limit_str = f"{data_limit_gb} ГБ"
|
||
|
||
info_text = (
|
||
f"📊 Ваша подписка:\n\n"
|
||
f"⏰ Действует до: {date_str}\n"
|
||
f"📦 Лимит трафика: {limit_str}\n"
|
||
f"📊 Использовано: {used_traffic:.2f} ГБ\n\n"
|
||
f"🎫 Ссылка на подписку:\n"
|
||
f"`{sub_url}`"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Error getting user info: {e}")
|
||
info_text = "⚠️ Ошибка получения данных. Попробуйте позже."
|
||
sub_url = "error"
|
||
|
||
# Клавиатура с кнопкой продления
|
||
sub_kb = InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="🔄 Продлить подписку", callback_data="buy_subscription")],
|
||
[InlineKeyboardButton(text="◀️ В главное меню", callback_data="back_to_main")]
|
||
])
|
||
|
||
try:
|
||
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")
|
||
|
||
await callback.message.delete()
|
||
await callback.message.answer_photo(
|
||
photo=qr_file,
|
||
caption=info_text,
|
||
reply_markup=sub_kb,
|
||
parse_mode="Markdown"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Error sending subscription photo: {e}")
|
||
await callback.message.answer(info_text, reply_markup=sub_kb, parse_mode="Markdown")
|
||
|
||
async def calculate_final_price(base_price: int, discount: int) -> int:
|
||
return int(base_price * (100 - discount) / 100)
|
||
|
||
@router.callback_query(F.data == "buy_subscription")
|
||
async def show_plans(callback: CallbackQuery):
|
||
user = await db.get_user(callback.from_user.id)
|
||
personal_desc = user['personal_discount'] if user and user['personal_discount'] else 0
|
||
|
||
text = (
|
||
"💎 <b>Выберите тарифный план:</b>\n\n"
|
||
"Все планы включают:\n"
|
||
"• Высокую скорость\n"
|
||
"• Поддержку всех устройств"
|
||
)
|
||
if personal_desc > 0:
|
||
text += f"\n\n🔥 Ваша персональная скидка: <b>{personal_desc}%</b>"
|
||
|
||
kb = plans_keyboard()
|
||
|
||
if callback.message.photo:
|
||
await callback.message.delete()
|
||
await callback.message.answer(text, reply_markup=kb, parse_mode="HTML")
|
||
else:
|
||
await callback.message.edit_text(text, reply_markup=kb, parse_mode="HTML")
|
||
|
||
@router.callback_query(F.data.startswith("plan_"))
|
||
async def process_plan_selection(callback: CallbackQuery, state: FSMContext):
|
||
plan_id = callback.data.replace("plan_", "", 1)
|
||
plan = PLANS[plan_id]
|
||
|
||
await state.update_data(selected_plan=plan_id)
|
||
data = await state.get_data()
|
||
|
||
# Check User Personal Discount
|
||
user = await db.get_user(callback.from_user.id)
|
||
personal_dist = user['personal_discount'] if user and user['personal_discount'] else 0
|
||
|
||
# Check Promo Discount
|
||
promo_dist = data.get('discount', 0)
|
||
|
||
# Effective Discount = Max of personal or promo
|
||
effective_discount = max(personal_dist, promo_dist)
|
||
|
||
# Calculate Price
|
||
final_price = await calculate_final_price(plan['price'], effective_discount)
|
||
await state.update_data(final_price=final_price)
|
||
|
||
msg = (
|
||
f"💎 Тариф: <b>{plan['name']}</b>\n"
|
||
f"📅 Срок: {plan['days']} дней\n"
|
||
f"📦 Трафик: {plan.get('limit_gb', '∞')} ГБ\n"
|
||
f"━━━━━━━━━━━━━━━\n"
|
||
)
|
||
|
||
if effective_discount > 0:
|
||
source_text = ""
|
||
if personal_dist >= promo_dist and personal_dist > 0:
|
||
source_text = "(персональная)"
|
||
elif promo_dist > 0:
|
||
source_text = "(промокод)"
|
||
|
||
msg += f"🔥 Скидка {effective_discount}% {source_text}\n"
|
||
msg += f"💰 Итого: <s>{plan['price']}</s> <b>{final_price} ⭐</b>"
|
||
else:
|
||
msg += f"💰 Цена: <b>{plan['price']} ⭐</b>"
|
||
|
||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="💳 Оплатить", callback_data="pay_now")],
|
||
[InlineKeyboardButton(text="🎟 Ввести промокод" if promo_dist == 0 else "❌ Сбросить промокод", callback_data="enter_promo" if promo_dist == 0 else "reset_promo")],
|
||
[InlineKeyboardButton(text="◀️ Назад", callback_data="buy_subscription")]
|
||
])
|
||
|
||
if callback.message.photo:
|
||
await callback.message.delete()
|
||
await callback.message.answer(msg, reply_markup=kb, parse_mode="HTML")
|
||
else:
|
||
await callback.message.edit_text(msg, reply_markup=kb, parse_mode="HTML")
|
||
|
||
@router.callback_query(F.data == "enter_promo")
|
||
async def ask_promo(callback: CallbackQuery, state: FSMContext):
|
||
await callback.message.edit_text("Введите промокод:")
|
||
await state.set_state(PromoStates.waiting_for_promo)
|
||
|
||
@router.message(PromoStates.waiting_for_promo)
|
||
async def process_promo(message: Message, state: FSMContext):
|
||
promo_code = message.text.strip().upper()
|
||
promo = await db.get_promo_code(promo_code)
|
||
|
||
if promo:
|
||
discount = promo['discount']
|
||
is_unlimited = promo['is_unlimited']
|
||
bonus_days = promo['bonus_days']
|
||
is_sticky = promo['is_sticky']
|
||
|
||
await state.update_data(
|
||
promo_code=promo_code,
|
||
discount=discount,
|
||
is_unlimited_promo=is_unlimited,
|
||
bonus_days=bonus_days
|
||
)
|
||
|
||
# VIP Check
|
||
if is_unlimited:
|
||
await state.update_data(final_price=0, discount=100)
|
||
await message.answer(
|
||
f"🌟 VIP Промокод активирован!\n"
|
||
f"Вы получите БЕЗЛИМИТНЫЙ доступ.",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="🚀 Активировать VIP", callback_data="pay_now")]
|
||
])
|
||
)
|
||
return
|
||
|
||
data = await state.get_data()
|
||
|
||
if 'selected_plan' in data:
|
||
plan_id = data['selected_plan']
|
||
plan = PLANS[plan_id]
|
||
|
||
# Recalculate with effective discount logic
|
||
user = await db.get_user(message.from_user.id)
|
||
personal_dist = user['personal_discount'] if user and user['personal_discount'] else 0
|
||
|
||
effective_discount = max(personal_dist, discount)
|
||
final_price = await calculate_final_price(plan['price'], effective_discount)
|
||
await state.update_data(final_price=final_price)
|
||
|
||
msg_text = ""
|
||
if bonus_days > 0 and discount == 0:
|
||
# Only bonus
|
||
msg_text += f"🎁 Бонус-код активирован! +{bonus_days} дней к тарифу.\n"
|
||
else:
|
||
msg_text += f"✅ Промокод на скидку {discount}% активирован!\n"
|
||
if is_sticky:
|
||
msg_text += "🔐 <b>Эта скидка закрепится за вами НАВСЕГДА после оплаты!</b>\n"
|
||
|
||
if personal_dist > discount:
|
||
msg_text += f"⚠️ У вас уже есть персональная скидка ({personal_dist}%), которая больше. Будет использована она."
|
||
|
||
msg_text += f"\nИтоговая цена: {final_price} ⭐"
|
||
|
||
await message.answer(
|
||
msg_text,
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="💳 Оплатить", callback_data="pay_now")],
|
||
[InlineKeyboardButton(text="◀️ Назад", callback_data="buy_subscription")],
|
||
])
|
||
)
|
||
else:
|
||
# Main menu activation logic
|
||
msg = f"✅ Промокод {promo_code} принят!"
|
||
if is_sticky:
|
||
msg += "\n🔐 Скидка будет закреплена за вами при следующей оплате."
|
||
|
||
await message.answer(
|
||
msg,
|
||
reply_markup=main_keyboard(message.from_user.id in CONFIG["ADMIN_IDS"])
|
||
)
|
||
else:
|
||
# Check context
|
||
data = await state.get_data()
|
||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="enter_promo")],
|
||
[InlineKeyboardButton(text="◀️ Отмена", callback_data="back_to_main")],
|
||
])
|
||
if 'selected_plan' in data:
|
||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="enter_promo")],
|
||
[InlineKeyboardButton(text="💳 Оплатить без промокода", callback_data="pay_now")],
|
||
])
|
||
|
||
await message.answer("❌ Промокод недействителен, просрочен или исчерпан.", reply_markup=kb)
|
||
|
||
await state.set_state(None)
|
||
|
||
@router.callback_query(F.data == "use_promo")
|
||
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 == "reset_promo")
|
||
async def reset_promo(callback: CallbackQuery, state: FSMContext):
|
||
await state.update_data(promo_code=None, discount=None, final_price=None, is_unlimited_promo=False)
|
||
data = await state.get_data()
|
||
plan_id = data.get('selected_plan')
|
||
|
||
if plan_id:
|
||
plan = PLANS[plan_id]
|
||
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")],
|
||
])
|
||
)
|
||
else:
|
||
await show_plans(callback)
|
||
|
||
@router.callback_query(F.data == "help")
|
||
async def help_handler(callback: CallbackQuery):
|
||
help_text = (
|
||
"ℹ️ **Помощь и Инструкции**\n\n"
|
||
"**Как настроить VPN?**\n"
|
||
"1️⃣ Нажмите «Моя подписка».\n"
|
||
"2️⃣ Скопируйте ссылку-конфиг (начинается с `vless://`).\n"
|
||
"3️⃣ Откройте приложение V2Ray/Hiddify и вставьте ссылку из буфера.\n"
|
||
"4️⃣ Нажмите кнопку подключения (большая кнопка).\n\n"
|
||
"<EFBFBD> **Приложения для скачивания:**\n\n"
|
||
"🍏 **iOS (iPhone/iPad):**\n"
|
||
"• [V2Box](https://apps.apple.com/us/app/v2box-v2ray-client/id6446814690)\n"
|
||
"• [FoXray](https://apps.apple.com/us/app/foxray/id6448898396)\n"
|
||
"• [Shadowrocket](https://apps.apple.com/us/app/shadowrocket/id932747118) (Платное, но лучшее)\n\n"
|
||
"🤖 **Android:**\n"
|
||
"• [v2rayNG](https://play.google.com/store/apps/details?id=com.v2ray.ang)\n"
|
||
"• [Hiddify Next](https://play.google.com/store/apps/details?id=app.hiddify.com)\n\n"
|
||
"💻 **Windows:**\n"
|
||
"• [v2rayN](https://github.com/2dust/v2rayN/releases)\n"
|
||
"• [Hiddify Next](https://github.com/hiddify/hiddify-next/releases)\n\n"
|
||
"❓ **Проблемы?**\n"
|
||
"Если не подключается — попробуйте обновить подписку или напишите админу: @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, parse_mode="Markdown")
|
||
else:
|
||
await callback.message.edit_text(help_text, reply_markup=kb, parse_mode="Markdown", disable_web_page_preview=True)
|
||
|
||
@router.callback_query(F.data == "back_to_main")
|
||
async def back_to_main(callback: CallbackQuery):
|
||
user = await db.get_user(callback.from_user.id)
|
||
is_admin = callback.from_user.id in CONFIG["ADMIN_IDS"]
|
||
active = is_active(user)
|
||
|
||
text = "Главное меню:"
|
||
kb = main_keyboard(is_admin, active)
|
||
|
||
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)
|