736 lines
33 KiB
Python
736 lines
33 KiB
Python
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)
|
||
|