Улучшение взаимодействия и добавление веб-приложения
This commit is contained in:
477
handlers/user.py
Normal file
477
handlers/user.py
Normal file
@@ -0,0 +1,477 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user