Улучшение взаимодействия и добавление веб-приложения

This commit is contained in:
2026-01-09 01:20:30 +03:00
parent eed252d52e
commit 2472947c1f
16 changed files with 2972 additions and 969 deletions

477
handlers/user.py Normal file
View 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)