Улучшение взаимодействия и добавление веб-приложения
This commit is contained in:
7
handlers/__init__.py
Normal file
7
handlers/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from . import user, admin, payment
|
||||
|
||||
routers = [
|
||||
user.router,
|
||||
admin.router,
|
||||
payment.router
|
||||
]
|
||||
735
handlers/admin.py
Normal file
735
handlers/admin.py
Normal file
@@ -0,0 +1,735 @@
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
import string
|
||||
import logging
|
||||
|
||||
from config import CONFIG
|
||||
from database import db
|
||||
from marzban import marzban
|
||||
from states import BroadcastStates, PromoStates, AdminUserStates
|
||||
from keyboards import admin_keyboard
|
||||
|
||||
# Add new states for user adding
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
class AddUserStates(StatesGroup):
|
||||
waiting_for_id = State()
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@router.callback_query(F.data == "admin_panel")
|
||||
async def admin_panel(callback: CallbackQuery):
|
||||
if callback.from_user.id not in CONFIG["ADMIN_IDS"]:
|
||||
await callback.answer("❌ Доступ запрещен", show_alert=True)
|
||||
return
|
||||
|
||||
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):
|
||||
if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return
|
||||
stats = await db.get_stats()
|
||||
text = (
|
||||
"📊 Статистика бота:\n\n"
|
||||
f"👥 Всего пользователей: {stats['total']}\n"
|
||||
f"✅ Активных подписок: {stats['active']}\n"
|
||||
f"💰 Общая выручка: {stats['revenue']} ⭐\n"
|
||||
)
|
||||
await callback.message.edit_text(text, reply_markup=admin_keyboard())
|
||||
|
||||
@router.callback_query(F.data == "admin_server_stats")
|
||||
async def admin_server_stats(callback: CallbackQuery):
|
||||
if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return
|
||||
try:
|
||||
sys = await marzban.get_system_stats()
|
||||
usrs = await marzban.get_users_stats()
|
||||
text = (
|
||||
"🖥️ Статистика сервера:\n\n"
|
||||
f"📊 CPU: {sys.get('cpu_usage', 'N/A')}%\n"
|
||||
f"💾 RAM: {sys.get('mem_used', 0)/(1024**3):.2f}/{sys.get('mem_total', 0)/(1024**3):.2f} GB\n"
|
||||
f"👥 Активных юзеров: {usrs.get('active_users', 0)}\n"
|
||||
f"📦 Всего трафика: {usrs.get('total_usage', 0)/(1024**3):.2f} GB"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
text = "⚠️ Ошибка Marzban API"
|
||||
await callback.message.edit_text(text, reply_markup=admin_keyboard())
|
||||
|
||||
# --- Add Invite / Direct User Add ---
|
||||
@router.callback_query(F.data == "admin_add_invite")
|
||||
async def admin_add_invite_menu(callback: CallbackQuery):
|
||||
if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return
|
||||
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔢 Сгенерировать код", callback_data="admin_gen_code")],
|
||||
[InlineKeyboardButton(text="👤 Добавить по ID/Username", callback_data="admin_add_user_direct")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
|
||||
])
|
||||
await callback.message.edit_text("Выберите способ добавления:", reply_markup=kb)
|
||||
|
||||
@router.callback_query(F.data == "admin_gen_code")
|
||||
async def admin_gen_code(callback: CallbackQuery):
|
||||
code = await db.create_invite_code(callback.from_user.id)
|
||||
|
||||
# Получаем юзернейм бота
|
||||
bot_info = await callback.bot.get_me()
|
||||
bot_username = bot_info.username
|
||||
|
||||
# Используем HTML, так как Markdown с подчеркиваниями часто ломается
|
||||
await callback.message.edit_text(
|
||||
f"✅ Новый инвайт-код:\n<code>{code}</code>\n\n"
|
||||
f"🔗 Ссылка для приглашения:\n"
|
||||
f"https://t.me/{bot_username}?start={code}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=admin_keyboard()
|
||||
)
|
||||
|
||||
@router.callback_query(F.data == "admin_add_user_direct")
|
||||
async def admin_add_user_direct(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"Введите Telegram ID (число) или Username (без @) пользователя:",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data="admin_panel")]])
|
||||
)
|
||||
await state.set_state(AddUserStates.waiting_for_id)
|
||||
|
||||
@router.message(AddUserStates.waiting_for_id)
|
||||
async def process_direct_add(message: Message, state: FSMContext):
|
||||
input_data = message.text.strip()
|
||||
|
||||
# Try to determine if it is ID or Username.
|
||||
# NOTE: We can only add by ID correctly IF the user has started the bot before (to get chat info),
|
||||
# OR if we just blindly trust the ID for the DB.
|
||||
# But usually, if adding by Username, we can't get ID easily without bot API interaction (get_chat).
|
||||
|
||||
user_id = None
|
||||
username = None
|
||||
|
||||
if input_data.isdigit():
|
||||
user_id = int(input_data)
|
||||
username = f"user_{user_id}"
|
||||
else:
|
||||
# Username logic is tricky because we need the numeric ID for the 'users' table primary key.
|
||||
# Without it, we can't insert into DB correctly if schema requires BIGINT KEY.
|
||||
# We'll try to resolve via bot API, but it often fails if bot never saw user.
|
||||
try:
|
||||
# Try to resolve chat? Bot API doesn't allow get_chat for users who didn't block bot, but...
|
||||
# Let's hope for the best or assume it's impossible without ID.
|
||||
await message.answer("⚠️ Добавление по юзернейму ненадежно без ID. Лучше используйте ID.")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if user_id:
|
||||
existing = await db.get_user(user_id)
|
||||
if existing:
|
||||
await message.answer("❌ Пользователь уже есть в базе.")
|
||||
else:
|
||||
marzban_username = f"user_{user_id}"
|
||||
await db.create_user(user_id, username, marzban_username, message.from_user.id)
|
||||
await message.answer(f"✅ Пользователь {user_id} добавлен в базу!")
|
||||
|
||||
await state.clear()
|
||||
await message.answer("Главное меню", reply_markup=main_keyboard(True))
|
||||
|
||||
# --- Promo Management ---
|
||||
@router.callback_query(F.data == "admin_promos")
|
||||
async def admin_promos(callback: CallbackQuery):
|
||||
if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return
|
||||
promos = await db.get_active_promos() # Only valid ones
|
||||
|
||||
text = "🏷 <b>Активные промокоды:</b>\n\n"
|
||||
if not promos:
|
||||
text = "Нет активных промокодов."
|
||||
|
||||
for p in promos:
|
||||
# Обработка даты (SQLite возвращает строку)
|
||||
exp_val = p['expires_at']
|
||||
exp_dt = None
|
||||
if exp_val:
|
||||
if isinstance(exp_val, str):
|
||||
try:
|
||||
exp_dt = datetime.strptime(exp_val, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
try:
|
||||
exp_dt = datetime.strptime(exp_val, '%Y-%m-%d %H:%M:%S.%f')
|
||||
except:
|
||||
pass
|
||||
elif isinstance(exp_val, datetime):
|
||||
exp_dt = exp_val
|
||||
|
||||
exp_str = exp_dt.strftime('%d.%m.%Y') if exp_dt else "∞"
|
||||
|
||||
# Получаем значения по ключам (не через get)
|
||||
is_unl = p['is_unlimited']
|
||||
type_str = " (VIP ∞)" if is_unl else f" (-{p['discount']}%)"
|
||||
|
||||
text += (
|
||||
f"🔹 <code>{p['code']}</code>{type_str}\n"
|
||||
f" Осталось: {p['uses_left']} | До: {exp_str}\n"
|
||||
)
|
||||
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="➕ Создать промокод", callback_data="admin_create_promo")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
|
||||
])
|
||||
await callback.message.edit_text(text, reply_markup=kb, parse_mode="HTML")
|
||||
|
||||
@router.callback_query(F.data == "admin_create_promo")
|
||||
async def start_create_promo(callback: CallbackQuery, state: FSMContext):
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="✍️ Свой вариант", callback_data="promo_name_custom")],
|
||||
[InlineKeyboardButton(text="🎲 Сгенерировать", callback_data="promo_name_generate")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
|
||||
])
|
||||
await callback.message.edit_text("Как задать название промокода?", reply_markup=kb)
|
||||
|
||||
@router.callback_query(F.data == "promo_name_custom")
|
||||
async def promo_name_custom(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text("Введите НАЗВАНИЕ промокода (например, NEWYEAR):")
|
||||
await state.set_state(PromoStates.waiting_for_name)
|
||||
|
||||
@router.callback_query(F.data == "promo_name_generate")
|
||||
async def promo_name_generate(callback: CallbackQuery, state: FSMContext):
|
||||
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
||||
await proceed_after_name(callback, state, code)
|
||||
|
||||
async def proceed_after_name(message_or_call, state: FSMContext, code: str):
|
||||
await state.update_data(code=code)
|
||||
|
||||
text = f"Название: <code>{code}</code>\n\nВведите размер скидки в % (от 0 до 100):\nМожно ввести 0, если это только бонус-код."
|
||||
|
||||
# Кнопка для быстрого VIP (чтобы не проходить все шаги если нужен просто VIP)
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="♾ Сделать VIP (Бесконечный)", callback_data="promo_make_vip")]
|
||||
])
|
||||
|
||||
if isinstance(message_or_call, CallbackQuery):
|
||||
await message_or_call.message.edit_text(text, reply_markup=kb, parse_mode="HTML")
|
||||
else:
|
||||
await message_or_call.answer(text, reply_markup=kb, parse_mode="HTML")
|
||||
|
||||
await state.set_state(PromoStates.waiting_for_discount)
|
||||
|
||||
@router.message(PromoStates.waiting_for_name)
|
||||
async def promo_name_entered(message: Message, state: FSMContext):
|
||||
await proceed_after_name(message, state, message.text.upper().strip())
|
||||
|
||||
@router.callback_query(F.data == "promo_make_vip")
|
||||
async def promo_make_vip(callback: CallbackQuery, state: FSMContext):
|
||||
# VIP shortcut
|
||||
await state.update_data(discount=100, is_unlimited=True, bonus_days=0)
|
||||
await callback.message.edit_text("Введите количество использований (число):")
|
||||
await state.set_state(PromoStates.waiting_for_uses)
|
||||
|
||||
@router.message(PromoStates.waiting_for_discount)
|
||||
async def promo_discount_step(message: Message, state: FSMContext):
|
||||
try:
|
||||
val = int(message.text)
|
||||
if not 0 <= val <= 100: raise ValueError
|
||||
await state.update_data(discount=val, is_unlimited=False)
|
||||
|
||||
if val > 0:
|
||||
# Если есть скидка, спрашиваем, закрепить ли её
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="✅ Да, закрепить цену навсегда", callback_data="promo_sticky_yes")],
|
||||
[InlineKeyboardButton(text="❌ Нет, одноразовая", callback_data="promo_sticky_no")]
|
||||
])
|
||||
await message.answer("Закрепить эту скидку за пользователем НАВСЕГДА? (Цена останется такой же при продлении)", reply_markup=kb)
|
||||
else:
|
||||
# Скидки нет, пропускаем шаг
|
||||
await state.update_data(is_sticky=False)
|
||||
await message.answer("Введите количество БОНУСНЫХ ДНЕЙ (0 если нет):")
|
||||
await state.set_state(PromoStates.waiting_for_bonus)
|
||||
|
||||
except:
|
||||
await message.answer("Введите число от 0 до 100!")
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_sticky_"))
|
||||
async def promo_sticky_callback(callback: CallbackQuery, state: FSMContext):
|
||||
is_sticky = (callback.data == "promo_sticky_yes")
|
||||
await state.update_data(is_sticky=is_sticky)
|
||||
|
||||
await callback.message.edit_text("Введите количество БОНУСНЫХ ДНЕЙ (0 если нет):")
|
||||
await state.set_state(PromoStates.waiting_for_bonus)
|
||||
|
||||
@router.message(PromoStates.waiting_for_bonus)
|
||||
async def promo_bonus_step(message: Message, state: FSMContext):
|
||||
try:
|
||||
val = int(message.text)
|
||||
if val < 0: raise ValueError
|
||||
await state.update_data(bonus_days=val)
|
||||
|
||||
await message.answer("Введите количество использований (число):")
|
||||
await state.set_state(PromoStates.waiting_for_uses)
|
||||
except:
|
||||
await message.answer("Введите положительное число или 0!")
|
||||
|
||||
|
||||
# Quick fix for the missing state step:
|
||||
# I will use `PromoStates.waiting_for_name` again but with flagging? No, bad practice.
|
||||
# Let's add the state to `states.py` in my mind, but since I can't edit that file instantly without tool,
|
||||
# I will just define a handler that catches "waiting_for_uses" + logic...
|
||||
# No, let's just make the user input days NOW in `promo_uses`?
|
||||
# Ah, I need to read the previous input first.
|
||||
# See below implementation.
|
||||
|
||||
# ... Redoing `promo_uses` to chaining correctly ...
|
||||
|
||||
@router.message(PromoStates.waiting_for_uses)
|
||||
async def promo_uses_step(message: Message, state: FSMContext):
|
||||
try:
|
||||
val = int(message.text)
|
||||
if val < 1: raise ValueError
|
||||
await state.update_data(uses=val)
|
||||
|
||||
# Кнопки с пресетами
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="♾ Навсегда", callback_data="days_0")],
|
||||
[InlineKeyboardButton(text="📅 30 дней", callback_data="days_30"),
|
||||
InlineKeyboardButton(text="📅 180 дней", callback_data="days_180")],
|
||||
[InlineKeyboardButton(text="📅 1 год", callback_data="days_365")],
|
||||
[InlineKeyboardButton(text="✍️ Свой вариант", callback_data="days_custom")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
"Сколько дней будет действовать промокод?\n"
|
||||
"Выберите вариант или введите число вручную:",
|
||||
reply_markup=kb
|
||||
)
|
||||
await state.set_state(PromoStates.waiting_for_days)
|
||||
except:
|
||||
await message.answer("Введите корректное число использований!")
|
||||
|
||||
# Общая функция создания
|
||||
async def create_promo_final(message_or_call, state: FSMContext, days: int):
|
||||
data = await state.get_data()
|
||||
|
||||
expires_at = None
|
||||
if days > 0:
|
||||
expires_at = datetime.now() + timedelta(days=days)
|
||||
|
||||
try:
|
||||
await db.create_promo_code(
|
||||
data['code'],
|
||||
data['discount'],
|
||||
data['uses'],
|
||||
message_or_call.from_user.id,
|
||||
expires_at,
|
||||
data.get('is_unlimited', False),
|
||||
data.get('bonus_days', 0),
|
||||
data.get('is_sticky', False)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating promo: {e}")
|
||||
error_text = "❌ Ошибка: Такой промокод уже существует или произошел сбой БД."
|
||||
if isinstance(message_or_call, CallbackQuery):
|
||||
await message_or_call.message.edit_text(error_text, reply_markup=admin_keyboard())
|
||||
else:
|
||||
await message_or_call.answer(error_text, reply_markup=admin_keyboard())
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
bonus = data.get('bonus_days', 0)
|
||||
is_sticky = data.get('is_sticky', False)
|
||||
|
||||
if data.get('is_unlimited'):
|
||||
type_text = "♾ VIP"
|
||||
else:
|
||||
parts = []
|
||||
if data['discount'] > 0:
|
||||
fixed = " (FIXED)" if is_sticky else ""
|
||||
parts.append(f"-{data['discount']}%{fixed}")
|
||||
if bonus > 0:
|
||||
parts.append(f"+{bonus}d")
|
||||
type_text = " ".join(parts) if parts else "Standard"
|
||||
|
||||
exp_text = expires_at.strftime('%d.%m.%Y') if expires_at else "Бессрочно"
|
||||
|
||||
confirm_text = (
|
||||
f"✅ <b>Промокод создан!</b>\n\n"
|
||||
f"Code: <code>{data['code']}</code>\n"
|
||||
f"Type: {type_text}\n"
|
||||
f"Uses: {data['uses']}\n"
|
||||
f"Expires: {exp_text}"
|
||||
)
|
||||
|
||||
if isinstance(message_or_call, CallbackQuery):
|
||||
await message_or_call.message.edit_text(confirm_text, reply_markup=admin_keyboard(), parse_mode="HTML")
|
||||
else:
|
||||
await message_or_call.answer(confirm_text, reply_markup=admin_keyboard(), parse_mode="HTML")
|
||||
|
||||
await state.clear()
|
||||
|
||||
@router.callback_query(PromoStates.waiting_for_days, F.data == "days_custom")
|
||||
async def promo_days_custom(callback: CallbackQuery):
|
||||
await callback.message.edit_text("✍️ Введите срок действия промокода в днях (целое число):")
|
||||
await callback.answer()
|
||||
|
||||
@router.callback_query(PromoStates.waiting_for_days, F.data.regexp(r"^days_\d+$"))
|
||||
async def promo_days_callback(callback: CallbackQuery, state: FSMContext):
|
||||
days = int(callback.data.split("_")[1])
|
||||
await create_promo_final(callback, state, days)
|
||||
|
||||
@router.message(PromoStates.waiting_for_days)
|
||||
async def promo_days_manual(message: Message, state: FSMContext):
|
||||
try:
|
||||
days = int(message.text)
|
||||
if days < 0: raise ValueError
|
||||
await create_promo_final(message, state, days)
|
||||
except:
|
||||
await message.answer("Введите число (0 или больше)!")
|
||||
|
||||
# --- Broadcast ---
|
||||
@router.callback_query(F.data == "admin_broadcast")
|
||||
async def admin_broadcast_start(callback: CallbackQuery, state: FSMContext):
|
||||
if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return
|
||||
await callback.message.edit_text("Сообщение для рассылки:", reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data="admin_panel")]]))
|
||||
await state.set_state(BroadcastStates.waiting_for_message)
|
||||
|
||||
@router.message(BroadcastStates.waiting_for_message)
|
||||
async def broadcast_go(message: Message, state: FSMContext):
|
||||
users = await db.get_users_for_broadcast()
|
||||
count = 0
|
||||
msg = await message.answer("Рассылаю...")
|
||||
for u in users:
|
||||
try:
|
||||
await message.send_copy(u['user_id'])
|
||||
count+=1
|
||||
except: pass
|
||||
await msg.edit_text(f"✅ Отправлено: {count}", reply_markup=admin_keyboard())
|
||||
await state.clear()
|
||||
|
||||
# --- User Management ---
|
||||
@router.callback_query(F.data == "admin_users_list")
|
||||
async def admin_users_list(callback: CallbackQuery):
|
||||
if callback.from_user.id not in CONFIG["ADMIN_IDS"]: return
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔍 Найти пользователя", callback_data="admin_search_user")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
|
||||
])
|
||||
await callback.message.edit_text("👥 Управление пользователями", reply_markup=kb)
|
||||
|
||||
@router.callback_query(F.data == "admin_search_user")
|
||||
async def admin_search_user(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"Введите Telegram ID или Username пользователя:",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data="admin_panel")]])
|
||||
)
|
||||
await state.set_state(AdminUserStates.waiting_for_search)
|
||||
|
||||
@router.message(AdminUserStates.waiting_for_search)
|
||||
async def process_user_search(message: Message, state: FSMContext):
|
||||
query = message.text.strip()
|
||||
users = await db.search_users(query)
|
||||
|
||||
if not users:
|
||||
await message.answer(
|
||||
"❌ Пользователи не найдены.\nПопробуйте другой запрос:",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data="admin_panel")]])
|
||||
)
|
||||
return
|
||||
|
||||
if len(users) == 1:
|
||||
await show_user_panel(message, users[0]["user_id"])
|
||||
await state.clear()
|
||||
else:
|
||||
# Show list
|
||||
kb_rows = []
|
||||
for u in users:
|
||||
display = u['username'] if u['username'] else f"ID: {u['user_id']}"
|
||||
kb_rows.append([InlineKeyboardButton(text=f"{display} | {u['user_id']}", callback_data=f"adm_sel_{u['user_id']}")])
|
||||
|
||||
kb_rows.append([InlineKeyboardButton(text="◀️ Отмена", callback_data="admin_users_list")])
|
||||
|
||||
await message.answer(f"🔍 Найдено {len(users)} пользователей:\nВыберите пользователя:", reply_markup=InlineKeyboardMarkup(inline_keyboard=kb_rows))
|
||||
await state.clear()
|
||||
|
||||
@router.callback_query(F.data.startswith("adm_sel_"))
|
||||
async def admin_user_select(callback: CallbackQuery):
|
||||
user_id = int(callback.data.split("_")[2])
|
||||
await show_user_panel(callback, user_id)
|
||||
|
||||
async def show_user_panel(message_or_call, user_id):
|
||||
user = await db.get_user(user_id)
|
||||
if not user:
|
||||
if isinstance(message_or_call, CallbackQuery): await message_or_call.answer("User not found")
|
||||
else: await message_or_call.answer("User not found")
|
||||
return
|
||||
|
||||
# Get marzban info
|
||||
marz_info = {}
|
||||
try:
|
||||
marz_info = await marzban.get_user(user['marzban_username'])
|
||||
except:
|
||||
pass
|
||||
|
||||
status = marz_info.get('status', 'Unknown')
|
||||
used_traffic = marz_info.get('used_traffic', 0)
|
||||
data_limit = marz_info.get('data_limit', 0)
|
||||
|
||||
traffic_used_gb = used_traffic / (1024**3) if used_traffic else 0
|
||||
traffic_limit_gb = data_limit / (1024**3) if data_limit else 0
|
||||
|
||||
sub_until = user['subscription_until']
|
||||
if sub_until and 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: pass
|
||||
|
||||
exp_str = sub_until.strftime('%d.%m.%Y %H:%M') if sub_until and isinstance(sub_until, datetime) else "Нет подписки"
|
||||
|
||||
username_display = user['username'] if user['username'] else str(user['user_id'])
|
||||
|
||||
status_icon = "🟢" if status == 'active' else "🔴"
|
||||
text = (
|
||||
f"👤 <b>Пользователь:</b> <a href='tg://user?id={user['user_id']}'>{username_display}</a>\n"
|
||||
f"🆔 ID: <code>{user['user_id']}</code>\n"
|
||||
f"🔋 Статус Marzban: {status_icon} {status}\n"
|
||||
f"📅 Подписка до: {exp_str}\n"
|
||||
f"📊 Трафик: {traffic_used_gb:.2f} / {traffic_limit_gb:.2f} GB\n"
|
||||
)
|
||||
|
||||
# Dynamic buttons
|
||||
status_btn = InlineKeyboardButton(text="⛔️ Заблокировать", callback_data=f"adm_usr_ban_{user_id}")
|
||||
if status == 'disabled':
|
||||
status_btn = InlineKeyboardButton(text="✅ Разблокировать", callback_data=f"adm_usr_unban_{user_id}")
|
||||
|
||||
rows = [
|
||||
[InlineKeyboardButton(text="➕ Продлить", callback_data=f"adm_usr_add_{user_id}"),
|
||||
InlineKeyboardButton(text="✏️ Лимит", callback_data=f"adm_usr_gb_{user_id}")],
|
||||
[status_btn,
|
||||
InlineKeyboardButton(text="🔄 Сброс трафика", callback_data=f"adm_usr_reset_{user_id}")]
|
||||
]
|
||||
|
||||
# Только если есть активная дата подписки
|
||||
if sub_until and isinstance(sub_until, datetime):
|
||||
rows.append([InlineKeyboardButton(text="❌ Удалить подписку", callback_data=f"adm_usr_delsub_{user_id}")])
|
||||
|
||||
rows.append([InlineKeyboardButton(text="✉️ Сообщение", callback_data=f"adm_usr_msg_{user_id}")])
|
||||
rows.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_users_list")])
|
||||
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
if isinstance(message_or_call, CallbackQuery):
|
||||
# We try to edit if possible, but if message content is same it errors.
|
||||
# However, status or buttons likely changed so it is fine.
|
||||
try:
|
||||
await message_or_call.message.edit_text(text, reply_markup=kb, parse_mode="HTML")
|
||||
except:
|
||||
await message_or_call.message.delete()
|
||||
await message_or_call.message.answer(text, reply_markup=kb, parse_mode="HTML")
|
||||
else:
|
||||
await message_or_call.answer(text, reply_markup=kb, parse_mode="HTML")
|
||||
|
||||
# Add Time
|
||||
@router.callback_query(F.data.startswith("adm_usr_add_"))
|
||||
async def adm_usr_add_start(callback: CallbackQuery, state: FSMContext):
|
||||
user_id = int(callback.data.split("_")[3])
|
||||
await state.update_data(target_user_id=user_id)
|
||||
await callback.message.edit_text(
|
||||
"Введите количество дней для добавления (целое число).\n"
|
||||
"Или отрицательное число, чтобы уменьшить срок.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data=f"admin_users_list")]])
|
||||
)
|
||||
await state.set_state(AdminUserStates.waiting_for_days)
|
||||
|
||||
@router.message(AdminUserStates.waiting_for_days)
|
||||
async def adm_usr_add_process(message: Message, state: FSMContext):
|
||||
try:
|
||||
days = int(message.text)
|
||||
data = await state.get_data()
|
||||
user_id = data['target_user_id']
|
||||
|
||||
user = await db.get_user(user_id)
|
||||
limit = user['data_limit'] if user['data_limit'] else 0
|
||||
|
||||
await db.update_subscription(user_id, days, limit)
|
||||
|
||||
# Update Marzban
|
||||
updated_user = await db.get_user(user_id)
|
||||
sub_until = updated_user['subscription_until']
|
||||
|
||||
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: pass
|
||||
|
||||
if sub_until:
|
||||
delta = sub_until - datetime.now()
|
||||
days_left = delta.days + 1 if delta.days >= 0 else 0
|
||||
else:
|
||||
days_left = 0
|
||||
|
||||
limit_gb = limit / (1024**3) if limit else 0
|
||||
|
||||
try:
|
||||
await marzban.modify_user(updated_user['marzban_username'], limit_gb, days_left)
|
||||
await message.answer(f"✅ Добавлено {days} дней пользователю {user_id}")
|
||||
except Exception as e:
|
||||
await message.answer(f"⚠️ БД обновлена, но Marzban вернул ошибку: {e}")
|
||||
|
||||
await show_user_panel(message, user_id)
|
||||
await state.clear()
|
||||
except ValueError:
|
||||
await message.answer("Ошибка. Введите целое число.")
|
||||
|
||||
# Reset Traffic
|
||||
@router.callback_query(F.data.startswith("adm_usr_reset_"))
|
||||
async def adm_usr_reset(callback: CallbackQuery):
|
||||
user_id = int(callback.data.split("_")[3])
|
||||
user = await db.get_user(user_id)
|
||||
if user:
|
||||
try:
|
||||
await marzban.reset_user_traffic(user['marzban_username'])
|
||||
await callback.answer("✅ Трафик сброшен", show_alert=True)
|
||||
except Exception as e:
|
||||
await callback.answer(f"Ошибка: {e}", show_alert=True)
|
||||
await show_user_panel(callback, user_id)
|
||||
|
||||
# Send Message
|
||||
@router.callback_query(F.data.startswith("adm_usr_msg_"))
|
||||
async def adm_usr_msg_start(callback: CallbackQuery, state: FSMContext):
|
||||
user_id = int(callback.data.split("_")[3])
|
||||
await state.update_data(target_user_id=user_id)
|
||||
await callback.message.edit_text(
|
||||
"Введите сообщение для отправки пользователю:",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data="admin_users_list")]])
|
||||
)
|
||||
await state.set_state(AdminUserStates.waiting_for_message)
|
||||
|
||||
@router.message(AdminUserStates.waiting_for_message)
|
||||
async def adm_usr_msg_send(message: Message, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
user_id = data['target_user_id']
|
||||
try:
|
||||
await message.send_copy(user_id)
|
||||
await message.answer(f"✅ Сообщение отправлено пользователю {user_id}")
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка отправки: {e}")
|
||||
|
||||
await show_user_panel(message, user_id)
|
||||
await state.clear()
|
||||
|
||||
# Ban/Unban
|
||||
@router.callback_query(F.data.regexp(r"^adm_usr_(ban|unban)_\d+$"))
|
||||
async def adm_usr_toggle_status(callback: CallbackQuery):
|
||||
action = callback.data.split("_")[2]
|
||||
user_id = int(callback.data.split("_")[3])
|
||||
new_status = "disabled" if action == "ban" else "active"
|
||||
|
||||
user = await db.get_user(user_id)
|
||||
if user:
|
||||
try:
|
||||
marz_user = await marzban.get_user(user['marzban_username'])
|
||||
current_limit = marz_user.get('data_limit')
|
||||
current_limit_gb = (current_limit / (1024**3)) if current_limit else 0
|
||||
|
||||
expire_ts = marz_user.get('expire')
|
||||
|
||||
await marzban.modify_user(user['marzban_username'], current_limit_gb, status=new_status, expire_timestamp=expire_ts)
|
||||
await callback.answer(f"Статус изменен на {new_status}", show_alert=True)
|
||||
except Exception as e:
|
||||
await callback.answer(f"Ошибка: {e}", show_alert=True)
|
||||
|
||||
await show_user_panel(callback, user_id)
|
||||
|
||||
# Limit Change
|
||||
@router.callback_query(F.data.startswith("adm_usr_gb_"))
|
||||
async def adm_usr_limit_start(callback: CallbackQuery, state: FSMContext):
|
||||
user_id = int(callback.data.split("_")[3])
|
||||
await state.update_data(target_user_id=user_id)
|
||||
await callback.message.edit_text(
|
||||
"Введите новый лимит трафика в GB (число):\n0 = Безлимит (если поддерживается)",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Отмена", callback_data=f"adm_sel_{user_id}")]])
|
||||
)
|
||||
await state.set_state(AdminUserStates.waiting_for_limit)
|
||||
|
||||
@router.message(AdminUserStates.waiting_for_limit)
|
||||
async def adm_usr_limit_process(message: Message, state: FSMContext):
|
||||
try:
|
||||
limit_gb = float(message.text)
|
||||
if limit_gb < 0: raise ValueError
|
||||
|
||||
data = await state.get_data()
|
||||
user_id = data['target_user_id']
|
||||
user = await db.get_user(user_id)
|
||||
|
||||
marz_user = await marzban.get_user(user['marzban_username'])
|
||||
expire_ts = marz_user.get('expire')
|
||||
|
||||
current_status = marz_user.get('status', 'active')
|
||||
await marzban.modify_user(user['marzban_username'], limit_gb, status=current_status, expire_timestamp=expire_ts)
|
||||
|
||||
limit_bytes = int(limit_gb * 1024 * 1024 * 1024)
|
||||
await db.execute("UPDATE users SET data_limit = $1 WHERE user_id = $2", limit_bytes, user_id)
|
||||
|
||||
await message.answer(f"✅ Лимит изменен на {limit_gb} GB")
|
||||
await show_user_panel(message, user_id)
|
||||
await state.clear()
|
||||
except ValueError:
|
||||
await message.answer("Введите корректное число!")
|
||||
|
||||
# Delete Subscription
|
||||
@router.callback_query(F.data.startswith("adm_usr_delsub_"))
|
||||
async def adm_usr_delsub_ask(callback: CallbackQuery):
|
||||
user_id = int(callback.data.split("_")[3])
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="✅ Да, удалить", callback_data=f"adm_usr_confirm_delsub_{user_id}")],
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"adm_sel_{user_id}")]
|
||||
])
|
||||
await callback.message.edit_text(
|
||||
f"⚠️ <b>Вы уверены, что хотите удалить подписку у пользователя {user_id}?</b>\n"
|
||||
"Пользователь потеряет доступ к VPN (срок действия истечет сейчас).",
|
||||
reply_markup=kb,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@router.callback_query(F.data.startswith("adm_usr_confirm_delsub_"))
|
||||
async def adm_usr_delsub_confirm(callback: CallbackQuery):
|
||||
user_id = int(callback.data.split("_")[4])
|
||||
user = await db.get_user(user_id)
|
||||
if not user:
|
||||
await callback.answer("Пользователь не найден")
|
||||
return
|
||||
|
||||
# Update DB
|
||||
await db.remove_subscription(user_id)
|
||||
|
||||
# Update Marzban
|
||||
try:
|
||||
# Expire immediately
|
||||
expire_ts = int(datetime.now().timestamp())
|
||||
marz_user = await marzban.get_user(user['marzban_username'])
|
||||
current_limit = marz_user.get('data_limit')
|
||||
current_limit_gb = (current_limit / (1024**3)) if current_limit else 0
|
||||
current_status = marz_user.get('status', 'active')
|
||||
|
||||
await marzban.modify_user(
|
||||
user['marzban_username'],
|
||||
current_limit_gb,
|
||||
status=current_status,
|
||||
expire_timestamp=expire_ts
|
||||
)
|
||||
await callback.answer("✅ Подписка удалена", show_alert=True)
|
||||
except Exception as e:
|
||||
await callback.answer(f"Ошибка Marzban: {e}", show_alert=True)
|
||||
|
||||
await show_user_panel(callback, user_id)
|
||||
|
||||
217
handlers/payment.py
Normal file
217
handlers/payment.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from config import CONFIG, PLANS
|
||||
from database import db
|
||||
from marzban import marzban
|
||||
from keyboards import main_keyboard
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def grant_subscription(user_id: int, plan_id: str, promo_code: str, amount: int, bonus_days: int = 0):
|
||||
total_days = 0
|
||||
data_limit = 0
|
||||
plan_name = "VIP Sub"
|
||||
marzban_days = 0
|
||||
|
||||
if plan_id:
|
||||
plan = PLANS[plan_id]
|
||||
total_days = plan['days'] + bonus_days
|
||||
marzban_days = total_days
|
||||
data_limit = plan['data_limit']
|
||||
plan_name = plan['name']
|
||||
else:
|
||||
# VIP case without plan
|
||||
total_days = 365 * 99 # For DB (99 years)
|
||||
marzban_days = 0 # For Marzban (Unlimited/None)
|
||||
data_limit = 0 # Unlimited
|
||||
plan_name = "VIP"
|
||||
|
||||
user = await db.get_user(user_id)
|
||||
|
||||
tg_username = user['username']
|
||||
note = f"@{tg_username}" if tg_username else ""
|
||||
|
||||
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:
|
||||
sub_until = datetime.strptime(sub_until, '%Y-%m-%d %H:%M:%S.%f')
|
||||
except: pass
|
||||
|
||||
# Marzban
|
||||
try:
|
||||
marzban_username = user['marzban_username']
|
||||
resp = None
|
||||
|
||||
# Если есть подписка и она активна (и не бесконечна, хотя тут не важно)
|
||||
is_sub_active = sub_until and sub_until > datetime.now()
|
||||
|
||||
if is_sub_active:
|
||||
logger.info(f"Attempting to modify existing user: {marzban_username}")
|
||||
resp = await marzban.modify_user(
|
||||
marzban_username,
|
||||
data_limit,
|
||||
marzban_days,
|
||||
note
|
||||
)
|
||||
|
||||
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,
|
||||
data_limit,
|
||||
marzban_days,
|
||||
note
|
||||
)
|
||||
else:
|
||||
logger.info(f"Creating/Reactivating user: {marzban_username}")
|
||||
resp = await marzban.create_user(
|
||||
marzban_username,
|
||||
data_limit,
|
||||
marzban_days,
|
||||
note
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Marzban error in grant_subscription: {e}")
|
||||
|
||||
# DB
|
||||
await db.update_subscription(user_id, total_days, data_limit)
|
||||
await db.add_payment(
|
||||
user_id,
|
||||
plan_id or "vip", # Store 'vip' as plan name/id
|
||||
amount,
|
||||
promo_code
|
||||
)
|
||||
|
||||
if promo_code:
|
||||
await db.decrement_promo_usage(promo_code)
|
||||
|
||||
# Return dummy plan dict for display
|
||||
return {'name': plan_name, 'days': total_days, 'data_limit': data_limit}, total_days
|
||||
|
||||
@router.callback_query(F.data == "pay_now")
|
||||
async def process_payment(callback: CallbackQuery, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
plan_id = data.get('selected_plan')
|
||||
is_vip = data.get('is_unlimited_promo')
|
||||
|
||||
if not plan_id and not is_vip:
|
||||
await callback.answer("❌ Сессия истекла. Пожалуйста, выберите тариф снова.", show_alert=True)
|
||||
return
|
||||
|
||||
# Если VIP без плана, ставим дефолтные значения
|
||||
plan_name = "VIP"
|
||||
plan_days = 3650
|
||||
plan_limit = 0
|
||||
base_price = 0
|
||||
|
||||
if plan_id:
|
||||
plan = PLANS[plan_id]
|
||||
plan_name = plan['name']
|
||||
plan_days = plan['days']
|
||||
plan_limit = plan['data_limit']
|
||||
base_price = plan['price']
|
||||
|
||||
final_price = int(data.get('final_price', base_price))
|
||||
promo_code = data.get('promo_code')
|
||||
bonus_days = data.get('bonus_days', 0)
|
||||
|
||||
if final_price <= 0:
|
||||
sticky_msg = ""
|
||||
if promo_code:
|
||||
p_data = await db.get_promo_code(promo_code)
|
||||
# Use explicit key access with fallback logic if needed, but keys exist
|
||||
if p_data and p_data['is_sticky']:
|
||||
user = await db.get_user(callback.from_user.id)
|
||||
u_disc = user['personal_discount'] if user and user['personal_discount'] else 0
|
||||
|
||||
if p_data['discount'] > u_disc:
|
||||
await db.set_user_discount(callback.from_user.id, p_data['discount'])
|
||||
sticky_msg = "\n🔐 Скидка закреплена навсегда!"
|
||||
|
||||
plan, date_days = await grant_subscription(callback.from_user.id, plan_id, promo_code, 0, bonus_days)
|
||||
await callback.message.edit_text(
|
||||
f"✅ Подписка активирована бесплатно!\n\n"
|
||||
f"План: {plan['name']}\n"
|
||||
f"Срок: {date_days} дней\n"
|
||||
f"Трафик: {plan['data_limit'] if plan['data_limit'] > 0 else '∞'} ГБ\n"
|
||||
f"{sticky_msg}\n"
|
||||
f"Настройте подключение в меню: 📊 Моя подписка",
|
||||
reply_markup=main_keyboard(callback.from_user.id in CONFIG["ADMIN_IDS"])
|
||||
)
|
||||
await state.clear()
|
||||
else:
|
||||
# Создаем инвойс для Telegram Stars
|
||||
await callback.message.answer_invoice(
|
||||
title=f"Подписка VPN - {plan['name']}",
|
||||
description=f"Трафик: {plan['data_limit']} ГБ на {plan['days']} дней",
|
||||
payload=f"{plan_id}:{data.get('promo_code', '')}",
|
||||
currency="XTR", # Telegram Stars
|
||||
prices=[LabeledPrice(label=plan['name'], amount=final_price)],
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Оплатить", pay=True)]
|
||||
])
|
||||
)
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def checkout_process(pre_checkout_query):
|
||||
await pre_checkout_query.answer(ok=True)
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def successful_payment(message: Message):
|
||||
payment = message.successful_payment
|
||||
plan_id, promo_code = payment.invoice_payload.split(":")
|
||||
|
||||
# We can reuse grant_subscription helper
|
||||
promo_code = promo_code if promo_code else None
|
||||
|
||||
bonus_days = 0
|
||||
sticky_text = ""
|
||||
|
||||
if promo_code:
|
||||
# Fetch actual promo details
|
||||
promo_data = await db.get_promo_code(promo_code)
|
||||
if promo_data:
|
||||
bonus_days = promo_data['bonus_days']
|
||||
|
||||
# STICKY LOGIC
|
||||
# Access by key (sqlite3.Row has no .get method)
|
||||
is_sticky = False
|
||||
try:
|
||||
is_sticky = promo_data['is_sticky']
|
||||
except IndexError:
|
||||
pass # Column missing?
|
||||
|
||||
if is_sticky:
|
||||
user = await db.get_user(message.from_user.id)
|
||||
current_discount = user['personal_discount'] if user and user['personal_discount'] else 0
|
||||
new_discount = promo_data['discount']
|
||||
|
||||
if new_discount > current_discount:
|
||||
await db.set_user_discount(message.from_user.id, new_discount)
|
||||
sticky_text = f"\n🔐 <b>Скидка {new_discount}% закреплена за вами НАВСЕГДА!</b>"
|
||||
|
||||
plan, date_days = await grant_subscription(
|
||||
message.from_user.id,
|
||||
plan_id,
|
||||
promo_code,
|
||||
payment.total_amount,
|
||||
bonus_days
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Оплата успешна!\n\n"
|
||||
f"Ваша подписка активирована на {date_days} дней.\n"
|
||||
f"Трафик: {plan['data_limit']} ГБ\n"
|
||||
f"{sticky_text}\n"
|
||||
f"Получите конфигурацию через меню: 📊 Моя подписка",
|
||||
reply_markup=main_keyboard(message.from_user.id in CONFIG["ADMIN_IDS"]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
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