diff --git a/backup.py b/backup.py index e44b45f1..10135d71 100644 --- a/backup.py +++ b/backup.py @@ -8,73 +8,60 @@ from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, P from logger import logger -async def backup_database(): - from bot import bot +async def backup_database() -> Exception | None: + backup_file_path, exception = _create_database_backup() + + if exception: + logger.error(f"Ошибка при создании бэкапа базы данных: {exception}") + return exception try: - if backup_file_path := _create_database_backup(): - await _send_backup_to_admin(bot, backup_file_path) - _cleanup_old_backups() + await _send_backup_to_admins(backup_file_path) + exception = _cleanup_old_backups() + + if exception: + logger.error(f"Ошибка при удалении старых бэкапов базы данных: {exception}") + return exception + + return None except Exception as e: - logger.error(f"Ошибка при создании или отправке бэкапа: {e}") + logger.error(f"Ошибка при отправке бэкапа базы данных: {e}") + return e -def _create_database_backup(): - USER = DB_USER - HOST = PG_HOST - PORT = PG_PORT - BACKUP_DIR = BACK_DIR - DATE = datetime.now().strftime("%Y-%m-%d-%H%M%S") - BACKUP_FILE = f"{BACKUP_DIR}/{DB_NAME}-backup-{DATE}.sql" - - os.environ["PGPASSWORD"] = DB_PASSWORD +def _create_database_backup() -> (str | None, Exception | None): + date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S") + filename = os.path.join(BACK_DIR, f"{DB_NAME}-backup-{date_formatted}.sql") try: + os.environ["PGPASSWORD"] = DB_PASSWORD + subprocess.run( [ "pg_dump", "-U", - USER, + DB_USER, "-h", - HOST, + PG_HOST, "-p", - PORT, + PG_PORT, "-F", "c", "-f", - BACKUP_FILE, + filename, DB_NAME, ], check=True, ) - logger.info(f"Бэкап базы данных создан: {BACKUP_FILE}") - return BACKUP_FILE + logger.info(f"Бэкап базы данных создан: {filename}") + return filename, None except subprocess.CalledProcessError as e: - logger.error(f"Ошибка при создании бэкапа базы данных: {e}") - return None + return None, e finally: del os.environ["PGPASSWORD"] -async def _send_backup_to_admin(bot, backup_file_path): - try: - with open(backup_file_path, "rb") as backup_file: - backup_input_file = BufferedInputFile( - backup_file.read(), filename=os.path.basename(backup_file_path) - ) - admin_ids: int | list[int] = ADMIN_ID - if isinstance(admin_ids, list): - for id in admin_ids: - await bot.send_document(id, backup_input_file) - logger.info(f"Бэкап базы данных отправлен админу: {id}") - else: - await bot.send_document(admin_ids, backup_input_file) - logger.info(f"Бэкап базы данных отправлен админу: {ADMIN_ID}") - except Exception as e: - logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}") - - -def _cleanup_old_backups(): +def _cleanup_old_backups() -> None | Exception: try: subprocess.run( [ @@ -94,10 +81,30 @@ def _cleanup_old_backups(): check=True, ) logger.info("Старые бэкапы удалены.") + return None except subprocess.CalledProcessError as e: - logger.error(f"Ошибка при удалении старых бэкапов: {e}") + return e -async def create_backup_and_send_to_admins(xui): +async def create_backup_and_send_to_admins(xui) -> None: await xui.login() await xui.database.export() + + +async def _send_backup_to_admins(backup_file_path: str) -> None: + try: + from bot import bot + with open(backup_file_path, "rb") as backup_file: + backup_input_file = BufferedInputFile( + file=backup_file.read(), + filename=os.path.basename(backup_file_path) + ) + admin_ids = ADMIN_ID if isinstance(ADMIN_ID, list) else [ADMIN_ID] + for admin_id in admin_ids: + await bot.send_document( + chat_id=admin_id, + document=backup_input_file + ) + logger.info(f"Бэкап базы данных отправлен админу: {admin_id}") + except Exception as e: + logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}") diff --git a/bot.py b/bot.py index 715fb7b2..947f5b42 100644 --- a/bot.py +++ b/bot.py @@ -18,6 +18,8 @@ bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTM storage = MemoryStorage() dp = Dispatcher(bot=bot, storage=storage) +version = "4.0.0-preAlpha(14-dev)" + dp.message.middleware(LoggingMiddleware()) dp.callback_query.middleware(LoggingMiddleware()) diff --git a/handlers/admin/__init__.py b/handlers/admin/__init__.py index 39109027..cb91e6f3 100644 --- a/handlers/admin/__init__.py +++ b/handlers/admin/__init__.py @@ -2,16 +2,26 @@ __all__ = ("router",) from aiogram import Router +from .admin_backups import router as backups_router +from .admin_bans import router as bans_router from .admin_coupons import router as coupons_router from .admin_panel import router as panel_router +from .admin_restart import router as restart_router +from .admin_sender import router as sender_router from .admin_servers import router as servers_router -from .admin_user_editor import router as user_editor_router +from .admin_stats import router as stats_router +from .admin_users import router as users_router router = Router(name="admins_main_router") router.include_routers( panel_router, servers_router, + users_router, + stats_router, + backups_router, + sender_router, coupons_router, - user_editor_router, + restart_router, + bans_router, ) diff --git a/handlers/admin/admin_backups.py b/handlers/admin/admin_backups.py new file mode 100644 index 00000000..51eee005 --- /dev/null +++ b/handlers/admin/admin_backups.py @@ -0,0 +1,35 @@ +from aiogram import F, Router +from aiogram.types import CallbackQuery + +from backup import backup_database +from filters.admin import IsAdminFilter +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb + +router = Router() + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "backups"), + IsAdminFilter(), +) +async def handle_backups( + callback_query: CallbackQuery +): + kb = build_admin_back_kb("management") + + await callback_query.message.edit_text( + text="💾 Инициализация резервного копирования базы данных...", + reply_markup=kb + ) + + exception = await backup_database() + + if exception: + text = f"❌ Ошибка при создании резервной копии: {exception}" + else: + text = "✅ Резервная копия успешно создана и отправлена администраторам." + + await callback_query.message.edit_text( + text=text, + reply_markup=kb + ) diff --git a/handlers/admin/admin_bans.py b/handlers/admin/admin_bans.py new file mode 100644 index 00000000..a515a14e --- /dev/null +++ b/handlers/admin/admin_bans.py @@ -0,0 +1,107 @@ +from typing import Any + +from aiogram import F, Router +from aiogram.types import CallbackQuery, BufferedInputFile + +from database import delete_user_data +from filters.admin import IsAdminFilter +from keyboards.admin.bans_kb import build_bans_kb +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb + +router = Router() + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "bans"), + IsAdminFilter(), +) +async def handle_bans( + callback_query: CallbackQuery +): + text = ( + "🚫 Заблокировавшие бота" + "\n\nЗдесь можно просматривать и удалять пользователей, которые забанили вашего бота!" + ) + + await callback_query.message.edit_text( + text=text, + reply_markup=build_bans_kb(), + ) + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "bans_export"), + IsAdminFilter(), +) +async def handle_bans_export( + callback_query: CallbackQuery, + session: Any +): + kb = build_admin_back_kb("management") + + try: + banned_users = await session.fetch("SELECT tg_id, blocked_at FROM blocked_users") + + import csv + import io + + csv_output = io.StringIO() + writer = csv.writer(csv_output) + writer.writerow(["tg_id", "blocked_at"]) + for user in banned_users: + writer.writerow([user["tg_id"], user["blocked_at"]]) + + csv_output.seek(0) + + document = BufferedInputFile( + file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv" + ) + + await callback_query.message.answer_document( + document=document, + caption="📥 Экспорт пользователей, заблокировавших бота в CSV", + ) + except Exception as e: + await callback_query.message.answer( + text=f"❗ Произошла ошибка при экспорте: {e}", + reply_markup=kb, + ) + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "bans_delete_banned"), + IsAdminFilter(), +) +async def handle_bans_delete_banned( + callback_query: CallbackQuery, + session: Any +): + kb = build_admin_back_kb("bans") + + try: + blocked_users = await session.fetch("SELECT tg_id FROM blocked_users") + blocked_ids = [record["tg_id"] for record in blocked_users] + + if not blocked_ids: + await callback_query.message.answer( + text="📂 Нет заблокировавших пользователей для удаления.", + reply_markup=kb, + ) + return + + for tg_id in blocked_ids: + await delete_user_data(session, tg_id) + + await session.execute( + "DELETE FROM blocked_users WHERE tg_id = ANY($1)", blocked_ids + ) + + await callback_query.message.answer( + text=f"🗑️ Удалены данные о {len(blocked_ids)} пользователях и связанных записях.", + reply_markup=kb, + ) + except Exception as e: + await callback_query.message.answer( + text=f"❗ Произошла ошибка при удалении записей: {e}", + reply_markup=kb, + ) diff --git a/handlers/admin/admin_coupons.py b/handlers/admin/admin_coupons.py index 27e3071e..d1a34644 100644 --- a/handlers/admin/admin_coupons.py +++ b/handlers/admin/admin_coupons.py @@ -3,56 +3,136 @@ from typing import Any from aiogram import F, Router, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup -from aiogram.types import InlineKeyboardButton -from aiogram.utils.keyboard import InlineKeyboardBuilder -from database import create_coupon, delete_coupon_from_db, get_all_coupons +from database import delete_coupon_from_db, get_all_coupons, create_coupon from filters.admin import IsAdminFilter +from keyboards.admin.coupons_kb import build_coupons_kb, build_coupons_list_kb, AdminCouponDeleteCallback +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb from logger import logger +router = Router() + class AdminCouponsState(StatesGroup): waiting_for_coupon_data = State() -router = Router() - - -@router.callback_query(F.data == "coupons_editor", IsAdminFilter()) -async def show_coupon_management_menu( - callback_query: types.CallbackQuery, state: FSMContext +@router.callback_query( + AdminPanelCallback.filter(F.action == "coupons"), + IsAdminFilter(), +) +async def handle_coupons( + callback_query: types.CallbackQuery, ): - await state.clear() - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="➕ Создать купон", callback_data="create_coupon") - ) - builder.row(InlineKeyboardButton(text="Купоны", callback_data="coupons")) - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin")) - await callback_query.message.answer( - "🛠 Меню управления купонами:", reply_markup=builder.as_markup() + await callback_query.message.edit_text( + text="🛠 Меню управления купонами:", + reply_markup=build_coupons_kb() ) -@router.callback_query(F.data == "coupons", IsAdminFilter()) -async def show_coupon_list(callback_query: types.CallbackQuery, session: Any): +@router.callback_query( + AdminPanelCallback.filter(F.action == "coupons_create"), + IsAdminFilter(), +) +async def handle_coupons_create( + callback_query: types.CallbackQuery, + state: FSMContext +): + text = ( + "🎫 Введите данные для создания купона в формате:\n\n" + "📝 код 💰 сумма 🔢 лимит\n\n" + "Пример: 'COUPON1 50 5' 👈\n\n" + ) + + await callback_query.message.edit_text( + text=text, + reply_markup=build_admin_back_kb("coupons"), + ) + await state.set_state(AdminCouponsState.waiting_for_coupon_data) + + +@router.message( + AdminCouponsState.waiting_for_coupon_data, + IsAdminFilter() +) +async def handle_coupon_data_input( + message: types.Message, + state: FSMContext, + session: Any +): + text = message.text.strip() + parts = text.split() + + kb = build_admin_back_kb("coupons") + + if len(parts) != 3: + text = ( + "❌ Некорректный формат! 📝 Пожалуйста, введите данные в формате:\n" + "🏷️ код 💰 сумма 🔢 лимит\n" + "Пример: 'COUPON1 50 5' 👈" + ) + + await message.answer( + text=text, + reply_markup=kb, + ) + return + + try: + coupon_code = parts[0] + coupon_amount = float(parts[1]) + usage_limit = int(parts[2]) + except ValueError: + text = ( + "⚠️ Проверьте правильность введенных данных!\n" + "💱 Сумма должна быть числом, а лимит — целым числом." + ) + + await message.answer( + text=text, + reply_markup=kb, + ) + return + + try: + await create_coupon(coupon_code, coupon_amount, usage_limit, session) + + text = ( + f"✅ Купон с кодом {coupon_code} успешно создан!\n" + f"💰 Сумма: {coupon_amount} рублей \n" + f"🔢 Лимит использования: {usage_limit} раз" + ) + + await message.answer( + text=text, + reply_markup=kb + ) + await state.clear() + + except Exception as e: + logger.error(f"Ошибка при создании купона: {e}") + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "coupons_list"), + IsAdminFilter(), +) +async def handle_coupons_list( + callback_query: types.CallbackQuery, + session: Any +): try: coupons = await get_all_coupons(session) if not coupons: - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor") - ) - - await callback_query.message.answer( - "❌ На данный момент нет доступных купонов. 🚫\nВы можете вернуться в меню управления. 🔙", - reply_markup=builder.as_markup(), + await callback_query.message.edit_text( + text="❌ На данный момент нет доступных купонов!", + reply_markup=build_admin_back_kb("coupons"), ) return + kb = build_coupons_list_kb(coupons) coupon_list = "📜 Список всех купонов:\n\n" - builder = InlineKeyboardBuilder() for coupon in coupons: coupon_list += ( @@ -62,103 +142,36 @@ async def show_coupon_list(callback_query: types.CallbackQuery, session: Any): f"✅ Использовано: {coupon['usage_count']} раз\n\n" ) - builder.row( - InlineKeyboardButton( - text=f"❌ Удалить {coupon['code']}", - callback_data=f"delete_coupon_{coupon['code']}", - ) - ) - - builder.row( - InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor") - ) - await callback_query.message.answer( - coupon_list, reply_markup=builder.as_markup() + await callback_query.message.edit_text( + text=coupon_list, + reply_markup=kb ) except Exception as e: logger.error(f"Ошибка при получении списка купонов: {e}") -@router.callback_query(F.data.startswith("delete_coupon_"), IsAdminFilter()) -async def handle_delete_coupon(callback_query: types.CallbackQuery, session: Any): - coupon_code = callback_query.data[len("delete_coupon_") :] +@router.callback_query( + AdminCouponDeleteCallback.filter(), + IsAdminFilter(), +) +async def handle_coupon_delete( + callback_query: types.CallbackQuery, + callback_data: AdminCouponDeleteCallback, + session: Any +): + coupon_code = callback_data.coupon_code try: result = await delete_coupon_from_db(coupon_code, session) if result: - await show_coupon_list(callback_query, session) + await handle_coupons_list(callback_query, session) else: - await callback_query.message.answer( - f"❌ Купон с кодом {coupon_code} не найден.", + await callback_query.message.edit_text( + text=f"❌ Купон с кодом {coupon_code} не найден.", ) - await show_coupon_list(callback_query, session) + await handle_coupons_list(callback_query, session) except Exception as e: logger.error(f"Ошибка при удалении купона: {e}") - - -@router.callback_query(F.data == "create_coupon", IsAdminFilter()) -async def handle_create_coupon(callback_query: types.CallbackQuery, state: FSMContext): - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")) - - await callback_query.message.answer( - "🎫 Введите данные для создания купона в формате:\n\n" - "📝 код 💰 сумма 🔢 лимит\n\n" - "Пример: 'COUPON1 50 5' 👈\n\n", - reply_markup=builder.as_markup(), - ) - await state.set_state(AdminCouponsState.waiting_for_coupon_data) - - -@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter()) -async def process_coupon_data(message: types.Message, state: FSMContext, session: Any): - text = message.text.strip() - - parts = text.split() - - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")) - - if len(parts) != 3: - await message.answer( - "❌ Некорректный формат! 📝 Пожалуйста, введите данные в формате:\n" - "🏷️ код 💰 сумма 🔢 лимит\n" - "Пример: 'COUPON1 50 5' 👈", - reply_markup=builder.as_markup(), - ) - return - - try: - coupon_code = parts[0] - coupon_amount = float(parts[1]) - usage_limit = int(parts[2]) - except ValueError: - await message.answer( - "⚠️ Проверьте правильность введенных данных!\n" - "💱 Сумма должна быть числом, 🔢 а лимит — целым числом.", - reply_markup=builder.as_markup(), - ) - return - - try: - await create_coupon(coupon_code, coupon_amount, usage_limit, session) - - result_message = ( - f"✅ Купон с кодом {coupon_code} успешно создан! 🎉\n" - f"Сумма: {coupon_amount} рублей 💰\n" - f"Лимит использования: {usage_limit} раз 🔢." - ) - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor") - ) - - await message.answer(result_message, reply_markup=builder.as_markup()) - await state.clear() - - except Exception as e: - logger.error(f"Ошибка при создании купона: {e}") diff --git a/handlers/admin/admin_panel.py b/handlers/admin/admin_panel.py index 23e14fe6..a5594caa 100644 --- a/handlers/admin/admin_panel.py +++ b/handlers/admin/admin_panel.py @@ -1,627 +1,63 @@ -import subprocess -from datetime import datetime -from io import BytesIO -from typing import Any - -import asyncpg from aiogram import F, Router, types from aiogram.filters import Command from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import State, StatesGroup -from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton -from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.types import CallbackQuery -from backup import backup_database -from bot import bot -from config import DATABASE_URL -from database import delete_user_data +from bot import version from filters.admin import IsAdminFilter -from logger import logger +from keyboards.admin.panel_kb import build_panel_kb, AdminPanelCallback, build_management_kb router = Router() -class UserEditorState(StatesGroup): - waiting_for_tg_id = State() - displaying_user_info = State() - waiting_for_restart_confirmation = State() - waiting_for_message = State() - - -@router.callback_query(F.data == "admin", IsAdminFilter()) +@router.callback_query( + AdminPanelCallback.filter(F.action == "admin"), + IsAdminFilter() +) async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext): - await handle_admin_message(callback_query.message, state) - - -@router.message(Command("admin"), F.data == "admin", IsAdminFilter()) -async def handle_admin_message(message: types.Message, state: FSMContext): - await state.clear() - - BOT_VERSION = "4.0.0-Alpha(21)" - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="📊 Статистика пользователей", callback_data="user_stats" - ) + text = ( + "🤖 Панель администратора" + f"\n📌 Версия бота: {version}" ) - builder.row( - InlineKeyboardButton( - text="👥 Управление пользователями", callback_data="user_editor" - ) - ) - builder.row( - InlineKeyboardButton( - text="🖥️ Управление серверами", callback_data="servers_editor" - ) - ) - builder.row( - InlineKeyboardButton( - text="🎟️ Управление купонами", callback_data="coupons_editor" - ) - ) - builder.row( - InlineKeyboardButton(text="📢 Массовая рассылка", callback_data="send_to") - ) - builder.row( - InlineKeyboardButton(text="🤖 Управление Ботом", callback_data="bot_management") - ) - builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) - await message.answer( - f"🤖 Панель администратора\n\nВерсия бота: {BOT_VERSION}", - reply_markup=builder.as_markup() - ) - - -@router.callback_query(F.data == "bot_management") -async def handle_bot_management(callback_query: types.CallbackQuery): - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="💾 Создать резервную копию", callback_data="backups") - ) - builder.row( - InlineKeyboardButton(text="🔄 Перезагрузить бота", callback_data="restart_bot") - ) - builder.row(InlineKeyboardButton(text="🚫 Баны", callback_data="ban_user")) - builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="admin")) - await callback_query.message.answer( - "🤖 Управление ботом", - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data == "user_stats", IsAdminFilter()) -async def user_stats_menu(callback_query: CallbackQuery, session: Any): - try: - total_users = await session.fetchval("SELECT COUNT(*) FROM users") - total_keys = await session.fetchval("SELECT COUNT(*) FROM keys") - total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals") - - total_payments_today = await session.fetchval( - "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE" - ) - total_payments_week = await session.fetchval( - "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)" - ) - total_payments_month = await session.fetchval( - "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)" - ) - total_payments_all_time = await session.fetchval( - "SELECT COALESCE(SUM(amount), 0) FROM payments" - ) - - registrations_today = await session.fetchval( - "SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE" - ) - registrations_week = await session.fetchval( - "SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('week', CURRENT_DATE)" - ) - registrations_month = await session.fetchval( - "SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('month', CURRENT_DATE)" - ) - - users_updated_today = await session.fetchval( - "SELECT COUNT(*) FROM users WHERE updated_at >= CURRENT_DATE" - ) - - active_keys = await session.fetchval( - "SELECT COUNT(*) FROM keys WHERE expiry_time > $1", - int(datetime.utcnow().timestamp() * 1000), - ) - expired_keys = total_keys - active_keys - - stats_message = ( - f"📊 Подробная статистика проекта:\n\n" - f"👥 Пользователи:\n" - f" 📅 За день: {registrations_today}\n" - f" 📆 За неделю: {registrations_week}\n" - f" 📆 За месяц: {registrations_month}\n" - f" 🌐 За все время: {total_users}\n\n" - f"🌟 Активные пользователи:\n" - f" 🌟 Активных сегодня: {users_updated_today}\n\n" - f"👥 Рефералы:\n" - f" 🤝 Всего привлечено: {total_referrals}\n\n" - f"🔑 Ключи:\n" - f" 🌈 Всего сгенерировано: {total_keys}\n" - f" ✅ Действующих: {active_keys}\n" - f" ❌ Просроченных: {expired_keys}\n\n" - f"💰 Финансовая статистика:\n" - f" 📅 За день: {total_payments_today} ₽\n" - f" 📆 За неделю: {total_payments_week} ₽\n" - f" 📆 За месяц: {total_payments_month} ₽\n" - f" 🏦 За все время: {total_payments_all_time} ₽\n" - ) - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="🔄 Обновить", callback_data="user_stats") - ) - builder.row( - InlineKeyboardButton( - text="📥 Выгрузить пользователей в CSV", - callback_data="export_users_csv", - ) - ) - builder.row( - InlineKeyboardButton( - text="📥 Выгрузить оплаты в CSV", callback_data="export_payments_csv" - ) - ) - builder.row( - InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin") - ) - - await callback_query.message.answer( - stats_message, reply_markup=builder.as_markup() - ) - except Exception as e: - logger.error(f"Error in user_stats_menu: {e}") - - -@router.callback_query(F.data == "export_users_csv", IsAdminFilter()) -async def export_users_csv(callback_query: CallbackQuery, session: Any): - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats")) - try: - users = await session.fetch( - """ - SELECT - u.tg_id, - u.username, - u.first_name, - u.last_name, - u.language_code, - u.is_bot, - c.balance, - c.trial - FROM users u - LEFT JOIN connections c ON u.tg_id = c.tg_id - """ - ) - - if not users: - await callback_query.message.answer( - "📭 Нет пользователей для экспорта.", reply_markup=builder.as_markup() - ) - return - - csv_data = ( - "tg_id,username,first_name,last_name,language_code,is_bot,balance,trial\n" - ) - for user in users: - csv_data += f"{user['tg_id']},{user['username']},{user['first_name']},{user['last_name']},{user['language_code']},{user['is_bot']},{user['balance']},{user['trial']}\n" - - file_name = BytesIO(csv_data.encode("utf-8-sig")) - file_name.seek(0) - - file = BufferedInputFile(file_name.getvalue(), filename="users_export.csv") - - await callback_query.message.answer_document( - file, - caption="📥 Экспорт пользователей в CSV", - reply_markup=builder.as_markup(), - ) - file_name.close() - - except Exception as e: - logger.error(f"Ошибка при экспорте пользователей в CSV: {e}") - await callback_query.message.answer( - "❗ Произошла ошибка при экспорте пользователей.", - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data == "export_payments_csv", IsAdminFilter()) -async def export_payments_csv(callback_query: CallbackQuery, session: Any): - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats")) - try: - payments = await session.fetch( - """ - SELECT - u.tg_id, - u.username, - u.first_name, - u.last_name, - p.amount, - p.payment_system, - p.status, - p.created_at - FROM users u - JOIN payments p ON u.tg_id = p.tg_id - """ - ) - - if not payments: - await callback_query.message.answer( - "📭 Нет платежей для экспорта.", reply_markup=builder.as_markup() - ) - return - - csv_data = "tg_id,username,first_name,last_name,amount,payment_system,status,created_at\n" # Заголовки CSV - for payment in payments: - csv_data += f"{payment['tg_id']},{payment['username']},{payment['first_name']},{payment['last_name']},{payment['amount']},{payment['payment_system']},{payment['status']},{payment['created_at']}\n" - - file_name = BytesIO(csv_data.encode("utf-8-sig")) - file_name.seek(0) - - file = BufferedInputFile(file_name.getvalue(), filename="payments_export.csv") - - await callback_query.message.answer_document( - file, caption="📥 Экспорт платежей в CSV", reply_markup=builder.as_markup() - ) - file_name.close() - - except Exception as e: - logger.error(f"Ошибка при экспорте платежей в CSV: {e}") - await callback_query.message.answer( - "❗ Произошла ошибка при экспорте платежей.", - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data == "send_to", IsAdminFilter()) -async def handle_send_to_all(callback_query: CallbackQuery, state: FSMContext): - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="📢 Отправить всем", callback_data="send_to_all") - ) - builder.row( - InlineKeyboardButton( - text="📢 Отправить с подпиской", callback_data="send_to_subscribed" - ) - ) - builder.row( - InlineKeyboardButton( - text="📢 Отправить без подписки", callback_data="send_to_unsubscribed" - ) - ) - builder.row( - InlineKeyboardButton( - text="📢 Рассылка по кластеру", callback_data="send_to_cluster" - ) - ) - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin")) - await callback_query.message.answer( - "✍️ Выберите группу пользователей и введите текст сообщения для рассылки:", - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data == "send_to_all", IsAdminFilter()) -async def handle_send_to_all(callback_query: CallbackQuery, state: FSMContext): - await state.update_data(send_to="all") - await callback_query.message.answer( - "✍️ Введите текст сообщения для рассылки всем пользователям:" - ) - await state.set_state(UserEditorState.waiting_for_message) - - -@router.callback_query(F.data == "send_to_subscribed", IsAdminFilter()) -async def handle_send_to_subscribed(callback_query: CallbackQuery, state: FSMContext): - await state.update_data(send_to="subscribed") - await callback_query.message.answer( - "✍️ Введите текст сообщения для рассылки пользователям с активной подпиской:" - ) - await state.set_state(UserEditorState.waiting_for_message) - - -@router.callback_query(F.data == "send_to_unsubscribed", IsAdminFilter()) -async def handle_send_to_unsubscribed(callback_query: CallbackQuery, state: FSMContext): - await state.update_data(send_to="unsubscribed") - await callback_query.message.answer( - "✍️ Введите текст сообщения для рассылки пользователям без активной подписки:" - ) - await state.set_state(UserEditorState.waiting_for_message) - - -@router.callback_query(F.data == "send_to_cluster", IsAdminFilter()) -async def handle_send_to_cluster( - callback_query: CallbackQuery, state: FSMContext, session: Any -): - clusters = await session.fetch("SELECT DISTINCT cluster_name FROM servers") - - builder = InlineKeyboardBuilder() - for cluster in clusters: - builder.row( - InlineKeyboardButton( - text=f"🌐 {cluster['cluster_name']}", - callback_data=f"send_cluster|{cluster['cluster_name']}", - ) - ) - - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="send_to")) - await callback_query.message.answer( - "✍️ Выберите кластер для рассылки сообщений:", - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data.startswith("send_cluster|"), IsAdminFilter()) -async def handle_send_cluster(callback_query: CallbackQuery, state: FSMContext): - cluster_name = callback_query.data.split("|")[1] - await state.update_data(send_to="cluster", cluster_name=cluster_name) - await callback_query.message.answer( - f"✍️ Введите текст сообщения для рассылки пользователям кластера {cluster_name}:" - ) - await state.set_state(UserEditorState.waiting_for_message) - - -@router.message(UserEditorState.waiting_for_message, IsAdminFilter()) -async def process_message_to_all( - message: types.Message, state: FSMContext, session: Any -): - text_message = message.text - - try: - state_data = await state.get_data() - send_to = state_data.get("send_to", "all") - - if send_to == "all": - tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections") - elif send_to == "subscribed": - tg_ids = await session.fetch( - """ - SELECT DISTINCT c.tg_id - FROM connections c - JOIN keys k ON c.tg_id = k.tg_id - WHERE k.expiry_time > $1 - """, - int(datetime.utcnow().timestamp() * 1000), - ) - elif send_to == "unsubscribed": - tg_ids = await session.fetch( - """ - SELECT c.tg_id - FROM connections c - LEFT JOIN keys k ON c.tg_id = k.tg_id - GROUP BY c.tg_id - HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1 - """, - int(datetime.utcnow().timestamp() * 1000), - ) - elif send_to == "cluster": - cluster_name = state_data.get("cluster_name") - tg_ids = await session.fetch( - """ - SELECT DISTINCT c.tg_id - FROM connections c - JOIN keys k ON c.tg_id = k.tg_id - JOIN servers s ON k.server_id = s.cluster_name - WHERE s.cluster_name = $1 - """, - cluster_name, - ) - - total_users = len(tg_ids) - success_count = 0 - error_count = 0 - - for record in tg_ids: - tg_id = record["tg_id"] - try: - await bot.send_message(chat_id=tg_id, text=text_message) - success_count += 1 - except Exception as e: - error_count += 1 - logger.error( - f"❌ Ошибка при отправке сообщения пользователю {tg_id}: {e}" - ) - - await message.answer( - f"📤 Рассылка завершена:\n" - f"👥 Всего пользователей: {total_users}\n" - f"✅ Успешно отправлено: {success_count}\n" - f"❌ Не доставлено: {error_count}" - ) - except Exception as e: - logger.error(f"❗ Ошибка при подключении к базе данных: {e}") await state.clear() - - -@router.callback_query(F.data == "backups", IsAdminFilter()) -async def handle_backup(callback_query: CallbackQuery, state: FSMContext): - await callback_query.message.answer( - "💾 Инициализация резервного копирования базы данных..." - ) - await backup_database() - await callback_query.message.answer( - "✅ Резервная копия успешно создана и отправлена администратору." - ) - - -@router.callback_query(F.data == "restart_bot", IsAdminFilter()) -async def handle_restart(callback_query: CallbackQuery, state: FSMContext): - await state.set_state(UserEditorState.waiting_for_restart_confirmation) - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="✅ Да, перезапустить", callback_data="confirm_restart" - ), - InlineKeyboardButton(text="❌ Нет, отмена", callback_data="admin"), - ) - builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin")) - await callback_query.message.answer( - "🤔 Вы уверены, что хотите перезапустить бота?", - reply_markup=builder.as_markup(), + await callback_query.message.edit_text( + text=text, + reply_markup=build_panel_kb() ) @router.callback_query( - F.data == "confirm_restart", - UserEditorState.waiting_for_restart_confirmation, - IsAdminFilter(), + F.data == "admin", + IsAdminFilter() ) -async def confirm_restart_bot(callback_query: CallbackQuery, state: FSMContext): - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin")) - try: - subprocess.run( - ["sudo", "systemctl", "restart", "bot.service"], - check=True, - capture_output=True, - text=True, - ) - await state.clear() - await callback_query.message.answer( - "🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup() - ) - except subprocess.CalledProcessError: - await callback_query.message.answer( - "🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup() - ) - except Exception as e: - await callback_query.message.answer( - f"⚠️ Ошибка при перезагрузке бота: {e.stderr}", - reply_markup=builder.as_markup(), - ) +async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext): + await handle_admin_message(callback_query.message, state) -@router.callback_query(F.data == "user_editor", IsAdminFilter()) -async def user_editor_menu(callback_query: CallbackQuery): - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🔍 Поиск по названию ключа", - callback_data="search_by_key_name", - ) +@router.message( + Command("admin"), + IsAdminFilter() +) +async def handle_admin_message(message: types.Message, state: FSMContext): + text = ( + "🤖 Панель администратора" + f"\n📌 Версия бота: {version}" ) - builder.row( - InlineKeyboardButton( - text="🆔 Поиск по Telegram ID", callback_data="search_by_tg_id" - ) - ) - builder.row( - InlineKeyboardButton( - text="🌐 Поиск по Username", callback_data="search_by_username" - ) - ) - builder.row(InlineKeyboardButton(text="🔙 Вернуться назад", callback_data="admin")) - await callback_query.message.answer( - "👇 Выберите способ поиска пользователя:", reply_markup=builder.as_markup() + + await state.clear() + await message.answer( + text=text, + reply_markup=build_panel_kb() ) -@router.callback_query(F.data == "ban_user") -async def handle_ban_user(callback_query: types.CallbackQuery): - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="📄 Выгрузить в CSV", callback_data="export_to_csv") +@router.callback_query( + AdminPanelCallback.filter(F.action == "management"), + IsAdminFilter() +) +async def handle_management(callback_query: CallbackQuery): + await callback_query.message.edit_text( + text="🤖 Управление ботом", + reply_markup=build_management_kb(), ) - builder.row( - InlineKeyboardButton( - text="🗑️ Удалить из БД", callback_data="delete_banned_users" - ) - ) - builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management")) - await callback_query.message.answer( - "🚫 Заблокировавшие бота\n\n" - "Здесь можно просматривать и удалять пользователей, которые забанили вашего бота!", - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data == "export_to_csv") -async def export_banned_users_to_csv(callback_query: types.CallbackQuery): - conn = await asyncpg.connect(DATABASE_URL) - try: - banned_users = await conn.fetch("SELECT tg_id, blocked_at FROM blocked_users") - - import csv - import io - - csv_output = io.StringIO() - writer = csv.writer(csv_output) - writer.writerow(["tg_id", "blocked_at"]) - for user in banned_users: - writer.writerow([user["tg_id"], user["blocked_at"]]) - - csv_output.seek(0) - - document = BufferedInputFile( - file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv" - ) - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management") - ) - - await callback_query.message.answer_document( - document=document, - caption="📄 Список заблокировавших бота пользователей", - reply_markup=builder.as_markup(), - ) - except Exception as e: - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management") - ) - await callback_query.message.answer( - text=f"Ошибка при выгрузке CSV: {e}", - reply_markup=builder.as_markup(), - ) - finally: - await conn.close() - - -@router.callback_query(F.data == "delete_banned_users") -async def delete_banned_users(callback_query: types.CallbackQuery): - conn = await asyncpg.connect(DATABASE_URL) - try: - blocked_users = await conn.fetch("SELECT tg_id FROM blocked_users") - blocked_ids = [record["tg_id"] for record in blocked_users] - - if not blocked_ids: - await callback_query.message.answer( - "📂 Нет заблокировавших пользователей для удаления." - ) - return - - for tg_id in blocked_ids: - await delete_user_data(conn, tg_id) - - await conn.execute( - "DELETE FROM blocked_users WHERE tg_id = ANY($1)", blocked_ids - ) - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management") - ) - await callback_query.message.answer( - text=f"🗑️ Удалено данные о {len(blocked_ids)} пользователях и связанных записях.", - reply_markup=builder.as_markup(), - ) - except Exception as e: - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management") - ) - await callback_query.message.answer( - text=f"Ошибка при удалении записей: {e}", - reply_markup=builder.as_markup(), - ) - finally: - await conn.close() diff --git a/handlers/admin/admin_restart.py b/handlers/admin/admin_restart.py new file mode 100644 index 00000000..157bf13b --- /dev/null +++ b/handlers/admin/admin_restart.py @@ -0,0 +1,49 @@ +import subprocess + +from aiogram import F, Router +from aiogram.types import CallbackQuery + +from filters.admin import IsAdminFilter +from keyboards.admin.panel_kb import build_restart_kb, AdminPanelCallback, build_admin_back_kb + +router = Router() + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "restart"), + IsAdminFilter(), +) +async def handle_restart(callback_query: CallbackQuery): + await callback_query.message.edit_text( + text="🤔 Вы уверены, что хотите перезагрузить бота?", + reply_markup=build_restart_kb(), + ) + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "restart_confirm"), + IsAdminFilter(), +) +async def handle_restart_confirm(callback_query: CallbackQuery): + kb = build_admin_back_kb() + try: + subprocess.run( + ["sudo", "systemctl", "restart", "bot.service"], + check=True, + capture_output=True, + text=True, + ) + await callback_query.message.edit_text( + text="🔄 Бот успешно перезагружен!", + reply_markup=kb + ) + except subprocess.CalledProcessError: + await callback_query.message.edit_text( + text="🔄 Бот успешно перезагружен!", + reply_markup=kb + ) + except Exception as e: + await callback_query.message.edit_text( + text=f"⚠️ Ошибка при перезагрузке бота: {e.stderr}", + reply_markup=kb + ) diff --git a/handlers/admin/admin_sender.py b/handlers/admin/admin_sender.py new file mode 100644 index 00000000..e82598bd --- /dev/null +++ b/handlers/admin/admin_sender.py @@ -0,0 +1,112 @@ +from datetime import datetime +from typing import Any + +from aiogram import F, Router, types +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import CallbackQuery + +from filters.admin import IsAdminFilter +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb +from keyboards.admin.sender_kb import build_sender_kb, AdminSenderCallback +from logger import logger + +router = Router() + + +class AdminSender(StatesGroup): + waiting_for_message = State() + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "sender"), + IsAdminFilter(), +) +async def handle_sender( + callback_query: CallbackQuery +): + await callback_query.message.edit_text( + text="✍️ Выберите группу пользователей для рассылки:", + reply_markup=build_sender_kb(), + ) + + +@router.callback_query( + AdminSenderCallback.filter(), + IsAdminFilter(), +) +async def handle_sender_callback( + callback_query: CallbackQuery, + callback_data: AdminSenderCallback, + state: FSMContext +): + await callback_query.message.edit_text( + text="✍️ Введите текст сообщения для рассылки:", + reply_markup=build_admin_back_kb("sender"), + ) + await state.update_data(type=callback_data.type) + await state.set_state(AdminSender.waiting_for_message) + + +@router.message( + AdminSender.waiting_for_message, + IsAdminFilter(), +) +async def handle_message_input( + message: types.Message, + state: FSMContext, + session: Any +): + text_message = message.text + + try: + state_data = await state.get_data() + send_to = state_data.get("type", "all") + + if send_to == "subscribed": + tg_ids = await session.fetch(""" + SELECT DISTINCT c.tg_id + FROM connections c + JOIN keys k ON c.tg_id = k.tg_id + WHERE k.expiry_time > $1 + """, int(datetime.utcnow().timestamp() * 1000)) + elif send_to == "unsubscribed": + tg_ids = await session.fetch(""" + SELECT c.tg_id + FROM connections c + LEFT JOIN keys k ON c.tg_id = k.tg_id + GROUP BY c.tg_id + HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1 + """, int(datetime.utcnow().timestamp() * 1000)) + else: + tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections") + + total_users = len(tg_ids) + success_count = 0 + + for record in tg_ids: + tg_id = record["tg_id"] + try: + await message.bot.send_message( + chat_id=tg_id, + text=text_message + ) + success_count += 1 + except Exception: + pass + + text = ( + f"📤 Рассылка завершена!" + f"\n\n👥 Всего пользователей: {total_users}" + f"\n✅ Доставлено: {success_count}" + f"\n❌ Не доставлено: {total_users - success_count}" + ) + + await message.answer( + text=text, + reply_markup=build_admin_back_kb("stats") + ) + except Exception as e: + logger.error(f"❗ Ошибка при подключении к базе данных: {e}") + + await state.clear() diff --git a/handlers/admin/admin_servers.py b/handlers/admin/admin_servers.py index 4fb6bb32..d190f1ee 100644 --- a/handlers/admin/admin_servers.py +++ b/handlers/admin/admin_servers.py @@ -1,24 +1,22 @@ -import asyncio - import asyncpg from aiogram import F, Router, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup -from aiogram.types import InlineKeyboardButton -from aiogram.utils.keyboard import InlineKeyboardBuilder from py3xui import AsyncApi from backup import create_backup_and_send_to_admins from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL from database import check_unique_server_name, get_servers_from_db from filters.admin import IsAdminFilter -from handlers.keys.key_utils import create_key_on_cluster -from logger import logger +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb +from keyboards.admin.servers_kb import build_manage_server_kb, \ + build_delete_server_kb, \ + build_manage_cluster_kb, build_clusters_editor_kb, AdminServerEditorCallback router = Router() -class UserEditorState(StatesGroup): +class AdminServersEditor(StatesGroup): waiting_for_cluster_name = State() waiting_for_api_url = State() waiting_for_inbound_id = State() @@ -26,112 +24,104 @@ class UserEditorState(StatesGroup): waiting_for_subscription_url = State() -@router.callback_query(F.data == "servers_editor", IsAdminFilter()) -async def handle_servers_editor(callback_query: types.CallbackQuery): +@router.callback_query( + AdminPanelCallback.filter(F.action == "servers"), + IsAdminFilter(), +) +async def handle_servers( + callback_query: types.CallbackQuery +): servers = await get_servers_from_db() - builder = InlineKeyboardBuilder() - - for cluster_name, cluster_servers in servers.items(): - builder.row( - InlineKeyboardButton( - text=f"⚙️ {cluster_name}", callback_data=f"manage_cluster|{cluster_name}" - ) - ) - - builder.row( - InlineKeyboardButton(text="➕ Добавить кластер", callback_data="add_cluster") - ) - builder.row(InlineKeyboardButton(text="🔙 Назад в админку", callback_data="admin")) - - await callback_query.message.answer( + text = ( "🔧 Управление кластерами\n\n" "📌 Здесь вы можете добавить новый кластер.\n\n" "🌐 Кластеры — это пространство серверов, в пределах которого создается подписка.\n" "💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n" - "⚠️ Важно: Кластеры удаляются автоматически, если удалить все серверы внутри них.\n\n", - parse_mode="HTML", - reply_markup=builder.as_markup(), + "⚠️ Важно: Кластеры удаляются автоматически, если удалить все серверы внутри них.\n\n" + ) + + await callback_query.message.edit_text( + text=text, + reply_markup=build_clusters_editor_kb(servers), ) -@router.callback_query(F.data == "add_cluster", IsAdminFilter()) -async def handle_add_cluster(callback_query: types.CallbackQuery, state: FSMContext): - await callback_query.message.answer( +@router.callback_query( + AdminPanelCallback.filter(F.action == "clusters_add"), + IsAdminFilter(), +) +async def handle_clusters_add( + callback_query: types.CallbackQuery, + state: FSMContext +): + text = ( "🔧 Введите имя нового кластера:\n\n" - "Имя кластера должно быть уникальным и на английском языке.\n" - "Пример: cluster2 или us_east_1", - parse_mode="HTML", + "Имя кластера должно быть уникальным!\n" + "Пример: cluster1 или us_east_1" ) - await state.set_state(UserEditorState.waiting_for_cluster_name) + await callback_query.message.edit_text( + text=text, + reply_markup=build_admin_back_kb("servers") + ) + + await state.set_state(AdminServersEditor.waiting_for_cluster_name) -@router.message(UserEditorState.waiting_for_cluster_name, IsAdminFilter()) -async def handle_cluster_name_input(message: types.Message, state: FSMContext): - cluster_name = message.text.strip() - - if cluster_name == "❌ Отменить": - await state.clear() - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🔧 Управление кластерами", callback_data="servers_editor" - ) - ) +@router.message( + AdminServersEditor.waiting_for_cluster_name, + IsAdminFilter() +) +async def handle_cluster_name_input( + message: types.Message, + state: FSMContext +): + if not message.text: await message.answer( - "Процесс создания кластера отменен. Вы вернулись в меню управления серверами.", - reply_markup=builder.as_markup(), + text="❌ Имя кластера не может быть пустым. Попробуйте снова.", + reply_markup=build_admin_back_kb("servers") ) return - if not cluster_name: - await message.answer("❌ Имя кластера не может быть пустым. Попробуйте снова.") - return - + cluster_name = message.text.strip() await state.update_data(cluster_name=cluster_name) - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor") + text = ( + f"Введите имя сервера для кластера {cluster_name}:\n\n" + "Рекомендуется указать локацию и номер сервера в имени.\n\n" + "Пример: de1, fra1, fi2" ) await message.answer( - f"Введите имя сервера для кластера {cluster_name}:\n\n" - "Рекомендуется указать локацию сервера в имени.\n\n" - "Пример: server-asia, server-europe", - parse_mode="HTML", - reply_markup=builder.as_markup(), + text=text, + reply_markup=build_admin_back_kb("servers"), ) - await state.set_state(UserEditorState.waiting_for_server_name) + + await state.set_state(AdminServersEditor.waiting_for_server_name) -@router.message(UserEditorState.waiting_for_server_name, IsAdminFilter()) -async def handle_server_name_input(message: types.Message, state: FSMContext): +@router.message( + AdminServersEditor.waiting_for_server_name, + IsAdminFilter() +) +async def handle_server_name_input( + message: types.Message, + state: FSMContext +): + if not message.text: + await message.answer( + text="❌ Имя сервера не может быть пустым. Попробуйте снова.", + reply_markup=build_admin_back_kb("servers") + ) + return + server_name = message.text.strip() - if server_name == "❌ Отменить": - await state.clear() - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🔧 Управление кластерами", callback_data="servers_editor" - ) - ) + if not await check_unique_server_name(server_name): await message.answer( - "Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.", - reply_markup=builder.as_markup(), - ) - return - - if not server_name: - await message.answer("❌ Имя сервера не может быть пустым. Попробуйте снова.") - return - - server_unique = await check_unique_server_name(server_name) - if not server_unique: - await message.answer( - "❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя." + text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.", + reply_markup=build_admin_back_kb("servers") ) return @@ -139,124 +129,107 @@ async def handle_server_name_input(message: types.Message, state: FSMContext): cluster_name = user_data.get("cluster_name") await state.update_data(server_name=server_name) - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor") - ) - - await message.answer( + text = ( f"Введите API URL для сервера {server_name} в кластере {cluster_name}:\n\n" "API URL должен быть в следующем формате:\n\n" "https://your_domain:port/panel_path\n\n" - "URL должен быть без слэша на конце!\n", - parse_mode="HTML", - reply_markup=builder.as_markup(), + "URL должен быть без слэша на конце!\n" ) - await state.set_state(UserEditorState.waiting_for_api_url) + + await message.answer( + text=text, + reply_markup=build_admin_back_kb("servers"), + ) + + await state.set_state(AdminServersEditor.waiting_for_api_url) -@router.message(UserEditorState.waiting_for_api_url, IsAdminFilter()) -async def handle_api_url_input(message: types.Message, state: FSMContext): - api_url = message.text.strip() - - if api_url == "❌ Отменить": - await state.clear() - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🔧 Управление кластерами", callback_data="servers_editor" - ) - ) +@router.message( + AdminServersEditor.waiting_for_api_url, + IsAdminFilter() +) +async def handle_api_url_input( + message: types.Message, + state: FSMContext +): + if not message.text or not message.text.strip().startswith("https://"): await message.answer( - "Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.", - reply_markup=builder.as_markup(), + text="❌ API URL должен начинаться с https://. Попробуйте снова.", + reply_markup=build_admin_back_kb("servers") ) return - if not api_url.startswith("https://"): - await message.answer( - "❌ API URL должен начинаться с https://. Попробуйте снова.", - parse_mode="HTML", - ) - return - - api_url = api_url.rstrip("/") + api_url = message.text.strip().rstrip("/") user_data = await state.get_data() cluster_name = user_data.get("cluster_name") server_name = user_data.get("server_name") await state.update_data(api_url=api_url) - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor") - ) - - await message.answer( + text = ( f"Введите subscription_url для сервера {server_name} в кластере {cluster_name}:\n\n" "Subscription URL должен быть в следующем формате:\n\n" "https://your_domain:port_sub/sub_path\n\n" "URL должен быть без слэша и имени клиента на конце!\n" - "Его можно увидеть в панели 3x-ui в информации о клиенте.", - parse_mode="HTML", - reply_markup=builder.as_markup(), + "Его можно увидеть в панели 3x-ui в информации о клиенте." ) - await state.set_state(UserEditorState.waiting_for_subscription_url) + + await message.answer( + text=text, + reply_markup=build_admin_back_kb("servers"), + ) + + await state.set_state(AdminServersEditor.waiting_for_subscription_url) -@router.message(UserEditorState.waiting_for_subscription_url, IsAdminFilter()) -async def handle_subscription_url_input(message: types.Message, state: FSMContext): - subscription_url = message.text.strip() - - if subscription_url == "❌ Отменить": - await state.clear() - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🔧 Управление кластерами", callback_data="servers_editor" - ) - ) +@router.message( + AdminServersEditor.waiting_for_subscription_url, + IsAdminFilter() +) +async def handle_subscription_url_input( + message: types.Message, + state: FSMContext +): + if not message.text or not message.text.strip().startswith("https://"): await message.answer( - "Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.", - reply_markup=builder.as_markup(), + text="❌ subscription_url должен начинаться с https://. Попробуйте снова.", + reply_markup=build_admin_back_kb("servers") ) return - if not subscription_url.startswith("https://"): - await message.answer( - "❌ subscription_url должен начинаться с https://. Попробуйте снова.", - parse_mode="HTML", - ) - return - - subscription_url = subscription_url.rstrip("/") + subscription_url = message.text.strip().rstrip("/") user_data = await state.get_data() cluster_name = user_data.get("cluster_name") server_name = user_data.get("server_name") await state.update_data(subscription_url=subscription_url) - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor") + text = ( + f"Введите inbound_id для сервера {server_name} в кластере {cluster_name}:\n\n" + "Это номер подключения vless в вашей панели 3x-ui. Обычно это 1 при чистой настройке по гайду.\n\n" ) await message.answer( - f"Введите inbound_id для сервера {server_name} в кластере {cluster_name}:\n\n" - "Это номер подключения vless в вашей панели 3x-ui. Обычно это 1 при чистой настройке по гайду.\n\n", - parse_mode="HTML", - reply_markup=builder.as_markup(), + text=text, + reply_markup=build_admin_back_kb("servers"), ) - await state.set_state(UserEditorState.waiting_for_inbound_id) + await state.set_state(AdminServersEditor.waiting_for_inbound_id) -@router.message(UserEditorState.waiting_for_inbound_id, IsAdminFilter()) -async def handle_inbound_id_input(message: types.Message, state: FSMContext): +@router.message( + AdminServersEditor.waiting_for_inbound_id, + IsAdminFilter() +) +async def handle_inbound_id_input( + message: types.Message, + state: FSMContext +): inbound_id = message.text.strip() if not inbound_id.isdigit(): await message.answer( - "❌ inbound_id должен быть числовым значением. Попробуйте снова." + text="❌ inbound_id должен быть числовым значением. Попробуйте снова.", + reply_markup=build_admin_back_kb("servers") ) return @@ -280,156 +253,62 @@ async def handle_inbound_id_input(message: types.Message, state: FSMContext): ) await conn.close() - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🔙 Назад к кластерам", callback_data="servers_editor" - ) - ) - await message.answer( - f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!", - reply_markup=builder.as_markup(), + text=f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!", + reply_markup=build_admin_back_kb("servers"), ) await state.clear() -@router.callback_query(F.data.startswith("manage_cluster|"), IsAdminFilter()) -async def handle_manage_cluster(callback_query: types.CallbackQuery, state: FSMContext): - cluster_name = callback_query.data.split("|")[1] +@router.callback_query( + AdminServerEditorCallback.filter(F.action == "clusters_manage"), + IsAdminFilter() +) +async def handle_clusters_manage( + callback_query: types.CallbackQuery, + callback_data: AdminServerEditorCallback, +): + cluster_name = callback_data.data servers = await get_servers_from_db() cluster_servers = servers.get(cluster_name, []) - builder = InlineKeyboardBuilder() - - for server in cluster_servers: - builder.row( - InlineKeyboardButton( - text=f"🌍 {server['server_name']}", - callback_data=f"manage_server|{server['server_name']}", - ) - ) - - builder.row( - InlineKeyboardButton( - text="➕ Добавить сервер", callback_data=f"add_server|{cluster_name}" - ) - ) - - builder.row( - InlineKeyboardButton( - text="🌐 Доступность серверов", - callback_data=f"server_availability|{cluster_name}", - ) - ) - - builder.row( - InlineKeyboardButton( - text="💾 Создать бэкап кластера", - callback_data=f"backup_cluster|{cluster_name}", - ) - ) - - builder.row( - InlineKeyboardButton( - text="🔄 Синхронизировать", - callback_data=f"sync_cluster|{cluster_name}", - ) - ) - - builder.row( - InlineKeyboardButton( - text="🔙 Назад в управление кластерами", callback_data="servers_editor" - ) - ) - - await callback_query.message.answer( - f"🔧 Управление серверами для кластера {cluster_name}", - reply_markup=builder.as_markup(), + await callback_query.message.edit_text( + text=f"🔧 Управление серверами для кластера {cluster_name}", + reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name), ) -@router.callback_query(F.data.startswith("sync_cluster|"), IsAdminFilter()) -async def sync_cluster_handler(callback_query: types.CallbackQuery): - """Обработчик для синхронизации ключей на всех серверах выбранного кластера.""" - cluster_name = callback_query.data.split("|")[1] - - conn = await asyncpg.connect(DATABASE_URL) - try: - query_keys = """ - SELECT tg_id, client_id, email, expiry_time - FROM keys - WHERE server_id = $1 - """ - keys_to_sync = await conn.fetch(query_keys, cluster_name) - - if not keys_to_sync: - await callback_query.message.answer( - f"❌ Нет ключей для синхронизации в кластере {cluster_name}.", - reply_markup=InlineKeyboardBuilder() - .row( - InlineKeyboardButton( - text="🔙 Назад", callback_data="servers_editor" - ) - ) - .as_markup(), - ) - return - - servers = await get_servers_from_db() - cluster_servers = servers.get(cluster_name, []) - - for key in keys_to_sync: - for server_info in cluster_servers: - try: - await create_key_on_cluster( - cluster_name, - key["tg_id"], - key["client_id"], - key["email"], - key["expiry_time"], - ) - await asyncio.sleep(0.6) - except Exception as e: - logger.error(f"Ошибка при добавлении ключа {key['client_id']} в кластер {cluster_name}: {e}") - - await callback_query.message.answer( - f"✅ Ключи успешно синхронизированы для кластера {cluster_name}.", - reply_markup=InlineKeyboardBuilder() - .row(InlineKeyboardButton(text="🔙 Назад", callback_data="servers_editor")) - .as_markup(), - ) - except Exception as e: - logger.error(f"Ошибка синхронизации ключей в кластере {cluster_name}: {e}") - await callback_query.message.answer( - f"❌ Произошла ошибка при синхронизации: {e}", - reply_markup=InlineKeyboardBuilder() - .row(InlineKeyboardButton(text="🔙 Назад", callback_data="servers_editor")) - .as_markup(), - ) - finally: - await conn.close() - - -@router.callback_query(F.data.startswith("server_availability|"), IsAdminFilter()) -async def handle_check_server_availability(callback_query: types.CallbackQuery): - cluster_name = callback_query.data.split("|")[1] +@router.callback_query( + AdminServerEditorCallback.filter(F.action == "servers_availability"), + IsAdminFilter() +) +async def handle_servers_availability( + callback_query: types.CallbackQuery, + callback_data: AdminServerEditorCallback +): + cluster_name = callback_data.data servers = await get_servers_from_db() cluster_servers = servers.get(cluster_name, []) if not cluster_servers: - await callback_query.answer(f"Кластер '{cluster_name}' не содержит серверов.") + await callback_query.message.answer( + text=f"Кластер '{cluster_name}' не содержит серверов." + ) return - in_progress_message = await callback_query.message.answer( + text = ( f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n" "Это может занять до 1 минуты, пожалуйста, подождите..." ) - availability_message = ( + in_progress_message = await callback_query.message.answer( + text=text + ) + + text = ( f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n" ) @@ -442,97 +321,83 @@ async def handle_check_server_availability(callback_query: types.CallbackQuery): await xui.login() online_users = len(await xui.client.online()) - availability_message += ( + text += ( f"🌍 {server['server_name']}: {online_users} активных пользователей.\n" ) except Exception as e: - availability_message += f"❌ {server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n" - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}" - ) - ) + text += f"❌ {server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n" await in_progress_message.edit_text( - availability_message, reply_markup=builder.as_markup() + text=text, + reply_markup=build_admin_back_kb("servers") ) - await callback_query.answer() - - -@router.callback_query(F.data.startswith("manage_server|"), IsAdminFilter()) -async def handle_manage_server(callback_query: types.CallbackQuery, state: FSMContext): - server_name = callback_query.data.split("|")[1] +@router.callback_query( + AdminServerEditorCallback.filter(F.action == "servers_manage"), + IsAdminFilter() +) +async def handle_servers_manage( + callback_query: types.CallbackQuery, + callback_data: AdminServerEditorCallback +): + server_name = callback_data.data servers = await get_servers_from_db() - server = None - cluster_name = None - for cluster, cluster_servers in servers.items(): - server = next( - (s for s in cluster_servers if s["server_name"] == server_name), None - ) - if server: - cluster_name = cluster - break + cluster_name, server = next( + ((c, s) for c, cs in servers.items() + for s in cs if s["server_name"] == server_name), + (None, None) + ) if server: api_url = server["api_url"] subscription_url = server["subscription_url"] inbound_id = server["inbound_id"] - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🗑️ Удалить", callback_data=f"delete_server|{server_name}" - ) - ) - builder.row( - InlineKeyboardButton( - text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}" - ) - ) - - await callback_query.message.answer( + text = ( f"🔧 Информация о сервере {server_name}:\n\n" f"📡 API URL: {api_url}\n" f"🌐 Subscription URL: {subscription_url}\n" - f"🔑 Inbound ID: {inbound_id}", - parse_mode="HTML", - reply_markup=builder.as_markup(), + f"🔑 Inbound ID: {inbound_id}" + ) + + await callback_query.message.edit_text( + text=text, + reply_markup=build_manage_server_kb(server_name, cluster_name), ) else: - await callback_query.message.answer("❌ Сервер не найден.") + await callback_query.message.edit_text( + text="❌ Сервер не найден." + ) -@router.callback_query(F.data.startswith("delete_server|"), IsAdminFilter()) -async def handle_delete_server(callback_query: types.CallbackQuery, state: FSMContext): - server_name = callback_query.data.split("|")[1] - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="✅ Да", callback_data=f"confirm_delete_server|{server_name}" - ), - InlineKeyboardButton( - text="❌ Нет", callback_data=f"manage_server|{server_name}" - ), - ) - - await callback_query.message.answer( - f"🗑️ Вы уверены, что хотите удалить сервер {server_name}?", - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data.startswith("confirm_delete_server|"), IsAdminFilter()) -async def handle_confirm_delete_server( - callback_query: types.CallbackQuery, state: FSMContext +@router.callback_query( + AdminServerEditorCallback.filter(F.action == "servers_delete"), + IsAdminFilter() +) +async def handle_servers_delete( + callback_query: types.CallbackQuery, + callback_data: AdminServerEditorCallback ): - server_name = callback_query.data.split("|")[1] + server_name = callback_data.data + + await callback_query.message.edit_text( + text=f"🗑️ Вы уверены, что хотите удалить сервер {server_name}?", + reply_markup=build_delete_server_kb(server_name), + ) + + +@router.callback_query( + AdminServerEditorCallback.filter(F.action == "servers_delete_confirm"), + IsAdminFilter() +) +async def handle_servers_delete_confirm( + callback_query: types.CallbackQuery, + callback_data: AdminServerEditorCallback +): + server_name = callback_data.data conn = await asyncpg.connect(DATABASE_URL) await conn.execute( @@ -543,43 +408,48 @@ async def handle_confirm_delete_server( ) await conn.close() - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🔙 Назад в управление кластерами", callback_data="servers_editor" - ) - ) - - await callback_query.message.answer( - f"🗑️ Сервер {server_name} успешно удален.", reply_markup=builder.as_markup() + await callback_query.message.edit_text( + text=f"🗑️ Сервер {server_name} успешно удален.", + reply_markup=build_admin_back_kb("servers") ) -@router.callback_query(F.data.startswith("add_server|"), IsAdminFilter()) -async def handle_add_server(callback_query: types.CallbackQuery, state: FSMContext): - cluster_name = callback_query.data.split("|")[1] +@router.callback_query( + AdminServerEditorCallback.filter(F.action == "servers_add"), + IsAdminFilter() +) +async def handle_servers_add( + callback_query: types.CallbackQuery, + callback_data: AdminServerEditorCallback, + state: FSMContext +): + cluster_name = callback_data.data await state.update_data(cluster_name=cluster_name) - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor") - ) - - await callback_query.message.answer( + text = ( f"Введите имя сервера для кластера {cluster_name}:\n\n" "Рекомендуется указать локацию сервера в имени.\n\n" - "Пример: server-asia, server-europe", - parse_mode="HTML", - reply_markup=builder.as_markup(), + "Пример: server-asia, server-europe" ) - await state.set_state(UserEditorState.waiting_for_server_name) + await callback_query.message.edit_text( + text=text, + reply_markup=build_admin_back_kb("servers"), + ) + + await state.set_state(AdminServersEditor.waiting_for_server_name) -@router.callback_query(F.data.startswith("backup_cluster|"), IsAdminFilter()) -async def handle_backup_cluster(callback_query: types.CallbackQuery): - cluster_name = callback_query.data.split("|")[1] +@router.callback_query( + AdminServerEditorCallback.filter(F.action == "clusters_backup"), + IsAdminFilter() +) +async def handle_clusters_backup( + callback_query: types.CallbackQuery, + callback_data: AdminServerEditorCallback, +): + cluster_name = callback_data.data servers = await get_servers_from_db() cluster_servers = servers.get(cluster_name, []) @@ -592,13 +462,12 @@ async def handle_backup_cluster(callback_query: types.CallbackQuery): ) await create_backup_and_send_to_admins(xui) - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="servers_editor")) - - await callback_query.message.answer( + text = ( f"Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!\n\n" - f"🔔 Бэкапы отправлены в боты панелей.", - parse_mode="HTML", - reply_markup=builder.as_markup(), + f"🔔 Бэкапы отправлены в боты панелей." + ) + + await callback_query.message.edit_text( + text=text, + reply_markup=build_admin_back_kb("servers"), ) - await callback_query.answer() diff --git a/handlers/admin/admin_stats.py b/handlers/admin/admin_stats.py new file mode 100644 index 00000000..0a74d69f --- /dev/null +++ b/handlers/admin/admin_stats.py @@ -0,0 +1,138 @@ +from datetime import datetime +from typing import Any + +from aiogram import F, Router +from aiogram.types import CallbackQuery + +from filters.admin import IsAdminFilter +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb +from keyboards.admin.stats_kb import build_stats_kb +from logger import logger +from utils.csv_export import export_payments_csv, export_users_csv + +router = Router() + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "stats"), + IsAdminFilter(), +) +async def handle_stats( + callback_query: CallbackQuery, + session: Any +): + try: + total_users = await session.fetchval("SELECT COUNT(*) FROM users") + total_keys = await session.fetchval("SELECT COUNT(*) FROM keys") + total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals") + + total_payments_today = await session.fetchval( + "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE" + ) + total_payments_week = await session.fetchval( + "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)" + ) + total_payments_month = await session.fetchval( + "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)" + ) + total_payments_all_time = await session.fetchval( + "SELECT COALESCE(SUM(amount), 0) FROM payments" + ) + + registrations_today = await session.fetchval( + "SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE" + ) + registrations_week = await session.fetchval( + "SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('week', CURRENT_DATE)" + ) + registrations_month = await session.fetchval( + "SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('month', CURRENT_DATE)" + ) + + users_updated_today = await session.fetchval( + "SELECT COUNT(*) FROM users WHERE updated_at >= CURRENT_DATE" + ) + + active_keys = await session.fetchval( + "SELECT COUNT(*) FROM keys WHERE expiry_time > $1", + int(datetime.utcnow().timestamp() * 1000), + ) + expired_keys = total_keys - active_keys + + stats_message = ( + f"📊 Подробная статистика проекта:\n\n" + f"👥 Пользователи:\n" + f" 📅 За день: {registrations_today}\n" + f" 📆 За неделю: {registrations_week}\n" + f" 📆 За месяц: {registrations_month}\n" + f" 🌐 За все время: {total_users}\n\n" + f"🌟 Активные пользователи:\n" + f" 🌟 Активных сегодня: {users_updated_today}\n\n" + f"👥 Рефералы:\n" + f" 🤝 Всего привлечено: {total_referrals}\n\n" + f"🔑 Ключи:\n" + f" 🌈 Всего сгенерировано: {total_keys}\n" + f" ✅ Действующих: {active_keys}\n" + f" ❌ Просроченных: {expired_keys}\n\n" + f"💰 Финансовая статистика:\n" + f" 📅 За день: {total_payments_today} ₽\n" + f" 📆 За неделю: {total_payments_week} ₽\n" + f" 📆 За месяц: {total_payments_month} ₽\n" + f" 🏦 За все время: {total_payments_all_time} ₽\n" + ) + + await callback_query.message.edit_text( + text=stats_message, + reply_markup=build_stats_kb() + ) + except Exception as e: + logger.error(f"Error in user_stats_menu: {e}") + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "stats_export_users_csv"), + IsAdminFilter(), +) +async def handle_export_users_csv( + callback_query: CallbackQuery, + session: Any +): + kb = build_admin_back_kb("stats") + + try: + export = await export_users_csv(session) + await callback_query.message.answer_document( + document=export, + caption="📥 Экспорт пользователей в CSV" + ) + except Exception as e: + logger.error(f"Ошибка при экспорте пользователей в CSV: {e}") + await callback_query.message.edit_text( + text=f"❗ Произошла ошибка при экспорте: {e}", + reply_markup=kb + ) + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "stats_export_payments_csv"), + IsAdminFilter(), +) +async def handle_export_payments_csv( + callback_query: CallbackQuery, + session: Any +): + kb = build_admin_back_kb("stats") + + try: + export = await export_payments_csv(session) + await callback_query.message.answer_document( + document=export, + caption="📥 Экспорт платежей в CSV" + ) + + except Exception as e: + logger.error(f"Ошибка при экспорте платежей в CSV: {e}") + await callback_query.message.edit_text( + text=f"❗ Произошла ошибка при экспорте: {e}", + reply_markup=kb + ) diff --git a/handlers/admin/admin_user_editor.py b/handlers/admin/admin_user_editor.py deleted file mode 100644 index db2be7ba..00000000 --- a/handlers/admin/admin_user_editor.py +++ /dev/null @@ -1,766 +0,0 @@ -import asyncio -from datetime import datetime -from typing import Any - -import pytz -from aiogram import Bot, F, Router, types -from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import State, StatesGroup -from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup -from aiogram.utils.keyboard import InlineKeyboardBuilder - -from config import TOTAL_GB -from database import ( - delete_user_data, - get_client_id_by_email, - get_servers_from_db, - restore_trial, - update_key_expiry, -) -from filters.admin import IsAdminFilter -from handlers.keys.key_utils import ( - delete_key_from_cluster, - delete_key_from_db, - renew_key_in_cluster, -) -from handlers.utils import sanitize_key_name -from logger import logger - -router = Router() - - -class UserEditorState(StatesGroup): - waiting_for_tg_id = State() - waiting_for_username = State() - displaying_user_info = State() - waiting_for_new_balance = State() - waiting_for_key_name = State() - waiting_for_expiry_time = State() - waiting_for_message_text = State() - - -@router.callback_query(F.data == "search_by_tg_id", IsAdminFilter()) -async def prompt_tg_id(callback_query: CallbackQuery, state: FSMContext): - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - await callback_query.message.answer( - "🔍 Введите Telegram ID клиента:", reply_markup=builder.as_markup() - ) - await state.set_state(UserEditorState.waiting_for_tg_id) - - -@router.callback_query(F.data == "search_by_username", IsAdminFilter()) -async def prompt_username(callback_query: CallbackQuery, state: FSMContext): - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - await callback_query.message.answer( - "🔍 Введите Username клиента:", reply_markup=builder.as_markup() - ) - await state.set_state(UserEditorState.waiting_for_username) - - -@router.message(UserEditorState.waiting_for_username, IsAdminFilter()) -async def handle_username_input( - message: types.Message, state: FSMContext, session: Any -): - username = message.text.strip().lstrip("@").replace("https://t.me/", "") - user_record = await session.fetchrow( - "SELECT tg_id FROM users WHERE username = $1", username - ) - - if not user_record: - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - await message.answer( - "🔍 Пользователь с указанным username не найден. 🚫", - reply_markup=builder.as_markup(), - ) - await state.clear() - return - - tg_id = user_record["tg_id"] - username = await session.fetchval( - "SELECT username FROM users WHERE tg_id = $1", tg_id - ) - balance = await session.fetchval( - "SELECT balance FROM connections WHERE tg_id = $1", tg_id - ) - key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id) - referral_count = await session.fetchval( - "SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id - ) - - if balance is None: - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - await message.answer( - "🚫 Пользователь с указанным tg_id не найден. 🔍", - reply_markup=builder.as_markup(), - ) - await state.clear() - return - - builder = InlineKeyboardBuilder() - - for (email,) in key_records: - builder.row( - InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}") - ) - - builder.row( - InlineKeyboardButton( - text="📝 Изменить баланс", - callback_data=f"change_balance_{tg_id}", - ) - ) - - builder.row( - InlineKeyboardButton( - text="🔄 Восстановить пробник", - callback_data=f"restore_trial_{tg_id}", - ) - ) - builder.row( - InlineKeyboardButton( - text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}" - ) - ) - builder.row( - InlineKeyboardButton( - text="🔄 Обновить клиента", callback_data=f"user_info|{tg_id}" - ) - ) - builder.row( - InlineKeyboardButton( - text="✉️ Отправить сообщение", callback_data=f"send_message_{tg_id}" - ) - ) - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - - user_info = ( - f"📊 Информация о пользователе:\n\n" - f"🆔 ID пользователя: {tg_id}\n" - f"👤 Логин пользователя: @{username}\n" - f"💰 Баланс: {balance}\n" - f"👥 Количество рефералов: {referral_count}\n" - f"🔑 Ключи (для редактирования нажмите на ключ):" - ) - await message.answer(user_info, reply_markup=builder.as_markup()) - await state.set_state(UserEditorState.displaying_user_info) - - -@router.callback_query(F.data.startswith("send_message_")) -async def handle_send_message(callback_query: types.CallbackQuery, state: FSMContext): - tg_id = callback_query.data.split("_")[2] - await state.update_data(target_tg_id=tg_id) - await callback_query.message.answer( - "✉️ Введите текст сообщения, которое вы хотите отправить пользователю." - ) - await state.set_state(UserEditorState.waiting_for_message_text) - - -@router.message(UserEditorState.waiting_for_message_text, IsAdminFilter()) -async def process_send_message(message: types.Message, state: FSMContext, bot: Bot): - data = await state.get_data() - target_tg_id = data.get("target_tg_id") - - if not target_tg_id: - await message.answer("🚫 Ошибка: ID пользователя не найден.") - await state.clear() - return - - try: - await bot.send_message(chat_id=target_tg_id, text=message.text) - await message.answer("✅ Сообщение успешно отправлено.") - except Exception as e: - await message.answer(f"❌ Не удалось отправить сообщение: {e}") - - await state.clear() - - -@router.message(UserEditorState.waiting_for_tg_id, F.text.isdigit(), IsAdminFilter()) -async def handle_tg_id_input(message: types.Message, state: FSMContext, session: Any): - tg_id = int(message.text) - username = await session.fetchval( - "SELECT username FROM users WHERE tg_id = $1", tg_id - ) - balance = await session.fetchval( - "SELECT balance FROM connections WHERE tg_id = $1", tg_id - ) - key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id) - referral_count = await session.fetchval( - "SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id - ) - - if balance is None: - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - await message.answer( - "❌ Пользователь с указанным tg_id не найден. 🔍", - reply_markup=builder.as_markup(), - ) - await state.clear() - return - - builder = InlineKeyboardBuilder() - - for (email,) in key_records: - builder.row( - InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}") - ) - - builder.row( - InlineKeyboardButton( - text="📝 Изменить баланс", - callback_data=f"change_balance_{tg_id}", - ) - ) - builder.row( - InlineKeyboardButton( - text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}" - ) - ) - builder.row( - InlineKeyboardButton( - text="🔄 Обновить клиента", callback_data=f"user_info|{tg_id}" - ) - ) - builder.row( - InlineKeyboardButton( - text="🔄 Восстановить пробник", - callback_data=f"restore_trial_{tg_id}", - ) - ) - builder.row( - InlineKeyboardButton( - text="✉️ Отправить сообщение", callback_data=f"send_message_{tg_id}" - ) - ) - - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - - user_info = ( - f"📊 Информация о пользователе:\n\n" - f"🆔 ID пользователя: {tg_id}\n" - f"👤 Логин пользователя: @{username}\n" - f"💰 Баланс: {balance}\n" - f"👥 Количество рефералов: {referral_count}\n" - f"🔑 Ключи (для редактирования нажмите на ключ):" - ) - await message.answer(user_info, reply_markup=builder.as_markup()) - await state.set_state(UserEditorState.displaying_user_info) - - -@router.callback_query(F.data.startswith("restore_trial_"), IsAdminFilter()) -async def handle_restore_trial(callback_query: types.CallbackQuery, session: Any): - tg_id = int(callback_query.data.split("_")[2]) - - await restore_trial(tg_id, session) - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🔙 Назад в меню администратора", callback_data="admin" - ) - ) - - await callback_query.message.answer( - "✅ Триал успешно восстановлен.", reply_markup=builder.as_markup() - ) - - -@router.callback_query(F.data.startswith("change_balance_"), IsAdminFilter()) -async def process_balance_change(callback_query: CallbackQuery, state: FSMContext): - tg_id = int(callback_query.data.split("_")[2]) - await state.update_data(tg_id=tg_id) - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - await callback_query.message.answer( - "💸 Введите новую сумму баланса:", reply_markup=builder.as_markup() - ) - await state.set_state(UserEditorState.waiting_for_new_balance) - - -@router.message(UserEditorState.waiting_for_new_balance, IsAdminFilter()) -async def handle_new_balance_input( - message: types.Message, state: FSMContext, session: Any -): - if not message.text.isdigit() or int(message.text) < 0: - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - await message.answer( - "❌ Пожалуйста, введите корректную сумму для изменения баланса.", - reply_markup=builder.as_markup(), - ) - return - - new_balance = int(message.text) - user_data = await state.get_data() - tg_id = user_data.get("tg_id") - - await session.execute( - "UPDATE connections SET balance = $1 WHERE tg_id = $2", - new_balance, - tg_id, - ) - - response_message = f"✅ Баланс успешно изменен на {new_balance}." - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🔙 Назад в меню администратора", - callback_data="admin", - ) - ) - await message.answer(response_message, reply_markup=builder.as_markup()) - await state.clear() - - -async def get_key_details(email, session): - - record = await session.fetchrow( - """ - SELECT k.key, k.expiry_time, k.server_id, c.tg_id, c.balance - FROM keys k - JOIN connections c ON k.tg_id = c.tg_id - WHERE k.email = $1 - """, - email, - ) - - if not record: - return None - - cluster_name = record["server_id"] - - moscow_tz = pytz.timezone("Europe/Moscow") - expiry_date = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=moscow_tz) - current_date = datetime.now(moscow_tz) - time_left = expiry_date - current_date - - if time_left.total_seconds() <= 0: - days_left_message = "Ключ истек." - elif time_left.days > 0: - days_left_message = f"Осталось дней: {time_left.days}" - else: - hours_left = time_left.seconds // 3600 - days_left_message = f"Осталось часов: {hours_left}" - - return { - "key": record["key"], - "expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"), - "days_left_message": days_left_message, - "server_name": cluster_name, - "balance": record["balance"], - "tg_id": record["tg_id"], - } - - -@router.callback_query(F.data.startswith("edit_key_"), IsAdminFilter()) -async def process_key_edit(callback_query: CallbackQuery, session: Any): - email = callback_query.data.split("_", 2)[2] - key_details = await get_key_details(email, session) - - if not key_details: - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - await callback_query.message.answer( - "🔍 Информация о ключе не найдена. 🚫", - reply_markup=builder.as_markup(), - ) - return - - response_message = ( - f"🔑 Ключ: {key_details['key']}\n" - f"⏰ Дата истечения: {key_details['expiry_date']}\n" - f"💰 Баланс пользователя: {key_details['balance']}\n" - f"🌐 Кластер: {key_details['server_name']}" - ) - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="ℹ️ Получить информацию о юзере", - callback_data=f"user_info|{key_details['tg_id']}", - ) - ) - builder.row( - InlineKeyboardButton( - text="⏳ Изменить время истечения", - callback_data=f"change_expiry|{email}", - ) - ) - builder.row( - InlineKeyboardButton( - text="❌ Удалить ключ", - callback_data=f"delete_key_admin|{email}", - ) - ) - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - - await callback_query.message.answer( - response_message, reply_markup=builder.as_markup() - ) - - -@router.callback_query(F.data == "search_by_key_name", IsAdminFilter()) -async def prompt_key_name(callback_query: CallbackQuery, state: FSMContext): - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - await callback_query.message.answer( - "🔑 Введите имя ключа:", reply_markup=builder.as_markup() - ) - await state.set_state(UserEditorState.waiting_for_key_name) - - -@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter()) -async def handle_key_name_input( - message: types.Message, state: FSMContext, session: Any -): - key_name = sanitize_key_name(message.text) - key_details = await get_key_details(key_name, session) - - if not key_details: - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - await message.answer( - "🚫 Пользователь с указанным именем ключа не найден.", - reply_markup=builder.as_markup(), - ) - await state.clear() - return - - response_message = ( - f"🔑 Ключ: {key_details['key']}\n" - f"⏰ Дата истечения: {key_details['expiry_date']}\n" - f"💰 Баланс пользователя: {key_details['balance']}\n" - f"🌐 Сервер: {key_details['server_name']}" - ) - - key_buttons = InlineKeyboardBuilder() - key_buttons.row( - InlineKeyboardButton( - text="ℹ️ Получить информацию о юзере", - callback_data=f"user_info|{key_details['tg_id']}", - ) - ) - key_buttons.row( - InlineKeyboardButton( - text="⏳ Изменить время истечения", - callback_data=f"change_expiry|{key_name}", - ) - ) - key_buttons.row( - InlineKeyboardButton( - text="❌ Удалить ключ", - callback_data=f"delete_key_admin|{key_name}", - ) - ) - key_buttons.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - - await message.answer(response_message, reply_markup=key_buttons.as_markup()) - await state.clear() - - -@router.callback_query(F.data.startswith("change_expiry|"), IsAdminFilter()) -async def prompt_expiry_change(callback_query: CallbackQuery, state: FSMContext): - email = callback_query.data.split("|")[1] - await callback_query.message.answer( - f"⏳ Введите новое время истечения для ключа {email} в формате YYYY-MM-DD HH:MM:SS:" - ) - await state.update_data(email=email) - await state.set_state(UserEditorState.waiting_for_expiry_time) - - -@router.message(UserEditorState.waiting_for_expiry_time, IsAdminFilter()) -async def handle_expiry_time_input( - message: types.Message, state: FSMContext, session: Any -): - user_data = await state.get_data() - email = user_data.get("email") - - if not email: - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - await message.answer( - "📧 Email не найден в состоянии. 🚫", reply_markup=builder.as_markup() - ) - await state.clear() - return - - try: - expiry_time_str = message.text - moscow_tz = pytz.timezone("Europe/Moscow") - expiry_time = int( - datetime.strptime(expiry_time_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=moscow_tz).timestamp() * 1000 - ) - - client_id = await get_client_id_by_email(email) - if client_id is None: - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor") - ) - await message.answer( - f"🚫 Клиент с email {email} не найден. 🔍", - reply_markup=builder.as_markup(), - ) - await state.clear() - return - - record = await session.fetchrow( - "SELECT server_id FROM keys WHERE client_id = $1", client_id - ) - if not record: - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor") - ) - await message.answer( - "🚫 Клиент не найден в базе данных. 🔍", - reply_markup=builder.as_markup(), - ) - await state.clear() - return - - clusters = await get_servers_from_db() - - async def update_key_on_all_servers(): - tasks = [] - for cluster_name, cluster_servers in clusters.items(): - for server in cluster_servers: - tasks.append( - asyncio.create_task( - renew_key_in_cluster( - cluster_name, - email, - client_id, - expiry_time, - total_gb=TOTAL_GB, - ) - ) - ) - await asyncio.gather(*tasks) - - await update_key_on_all_servers() - - await update_key_expiry(client_id, expiry_time) - - response_message = f"✅ Время истечения ключа для клиента {client_id} ({email}) успешно обновлено на всех серверах." - - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin")) - await message.answer(response_message, reply_markup=builder.as_markup()) - except ValueError: - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - await message.answer( - "❌ Пожалуйста, используйте формат: YYYY-MM-DD HH:MM:SS.", - reply_markup=builder.as_markup(), - ) - except Exception as e: - logger.error(e) - await state.clear() - - -@router.callback_query(F.data.startswith("delete_key_admin|"), IsAdminFilter()) -async def process_callback_delete_key( - callback_query: types.CallbackQuery, session: Any -): - email = callback_query.data.split("|")[1] - client_id = await session.fetchval( - "SELECT client_id FROM keys WHERE email = $1", email - ) - - if client_id is None: - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - await callback_query.message.answer( - "🔍 Ключ не найден. 🚫", reply_markup=builder.as_markup() - ) - return - - builder = InlineKeyboardBuilder() - builder.row( - types.InlineKeyboardButton( - text="✅ Да, удалить", - callback_data=f"confirm_delete_admin|{client_id}", - ) - ) - builder.row( - types.InlineKeyboardButton(text="❌ Нет, отменить", callback_data="user_editor") - ) - await callback_query.message.answer( - "❓ Вы уверены, что хотите удалить ключ?", - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data.startswith("confirm_delete_admin|"), IsAdminFilter()) -async def process_callback_confirm_delete( - callback_query: types.CallbackQuery, session: Any -): - client_id = callback_query.data.split("|")[1] - record = await session.fetchrow( - "SELECT email FROM keys WHERE client_id = $1", client_id - ) - - if record: - email = record["email"] - response_message = "✅ Ключ успешно удален." - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys")) - - clusters = await get_servers_from_db() - - async def delete_key_from_servers(email, client_id): - tasks = [] - for cluster_name, cluster_servers in clusters.items(): - for server in cluster_servers: - tasks.append( - delete_key_from_cluster(cluster_name, email, client_id) - ) - await asyncio.gather(*tasks) - - await delete_key_from_servers(email, client_id) - await delete_key_from_db(client_id, session) - - await callback_query.message.answer( - response_message, reply_markup=builder.as_markup() - ) - else: - response_message = "🚫 Ключ не найден или уже удален." - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys")) - await callback_query.message.answer( - response_message, reply_markup=builder.as_markup() - ) - - -@router.callback_query(F.data.startswith("user_info|"), IsAdminFilter()) -async def handle_user_info( - callback_query: types.CallbackQuery, state: FSMContext, session: Any -): - tg_id = int(callback_query.data.split("|")[1]) - username = await session.fetchval( - "SELECT username FROM users WHERE tg_id = $1", tg_id - ) - balance = await session.fetchval( - "SELECT balance FROM connections WHERE tg_id = $1", tg_id - ) - key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id) - referral_count = await session.fetchval( - "SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id - ) - - builder = InlineKeyboardBuilder() - - for (email,) in key_records: - builder.row( - InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}") - ) - - builder.row( - InlineKeyboardButton( - text="📝 Изменить баланс", callback_data=f"change_balance_{tg_id}" - ) - ) - builder.row( - InlineKeyboardButton( - text="🔄 Восстановить пробник", callback_data=f"restore_trial_{tg_id}" - ) - ) - builder.row( - InlineKeyboardButton( - text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}" - ) - ) - builder.row( - InlineKeyboardButton( - text="🔄 Обновить клиента", callback_data=f"user_info|{tg_id}" - ) - ) - builder.row( - InlineKeyboardButton( - text="✉️ Отправить сообщение", callback_data=f"send_message_{tg_id}" - ) - ) - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")) - - user_info = ( - f"📊 Информация о пользователе:\n\n" - f"🆔 ID пользователя: {tg_id}\n" - f"👤 Логин пользователя: @{username}\n" - f"💰 Баланс: {balance}\n" - f"👥 Количество рефералов: {referral_count}\n" - f"🔑 Ключи (для редактирования нажмите на ключ):" - ) - - await callback_query.message.answer(user_info, reply_markup=builder.as_markup()) - await state.set_state(UserEditorState.displaying_user_info) - - -@router.callback_query(F.data.startswith("confirm_delete_user_"), IsAdminFilter()) -async def confirm_delete_user( - callback_query: types.CallbackQuery, state: FSMContext, session: Any -): - tg_id = int(callback_query.data.split("_")[3]) - - confirmation_markup = InlineKeyboardMarkup( - row_width=2, - inline_keyboard=[ - [ - InlineKeyboardButton( - text="✅ Подтвердить", callback_data=f"delete_user_{tg_id}" - ) - ], - [InlineKeyboardButton(text="❌ Отменить", callback_data="user_editor")], - ], - ) - - await callback_query.message.answer( - f"Вы уверены, что хотите удалить пользователя с ID {tg_id}?", - reply_markup=confirmation_markup, - ) - - -@router.callback_query(F.data.startswith("delete_user_"), IsAdminFilter()) -async def delete_user(callback_query: types.CallbackQuery, session: Any): - tg_id = int(callback_query.data.split("_")[2]) - - key_records = await session.fetch( - "SELECT email, client_id FROM keys WHERE tg_id = $1", tg_id - ) - - async def delete_keys_from_servers(): - try: - tasks = [] - for email, client_id in key_records: - servers = await get_servers_from_db() - for cluster_id, cluster in servers.items(): - tasks.append(delete_key_from_cluster(cluster_id, email, client_id)) - await asyncio.gather(*tasks) - except Exception as e: - logger.error( - f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}" - ) - - await delete_keys_from_servers() - - try: - await delete_user_data(session, tg_id) - - back_button = InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor") - keyboard = InlineKeyboardMarkup(inline_keyboard=[[back_button]]) - - await callback_query.message.answer( - f"🗑️ Пользователь с ID {tg_id} был удален.", reply_markup=keyboard - ) - except Exception as e: - logger.error( - f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}" - ) - await callback_query.message.answer( - f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова." - ) diff --git a/handlers/admin/admin_users.py b/handlers/admin/admin_users.py new file mode 100644 index 00000000..2520299a --- /dev/null +++ b/handlers/admin/admin_users.py @@ -0,0 +1,886 @@ +import asyncio +from datetime import datetime, timezone +from typing import Any + +from aiogram import F, Router, types +from aiogram.exceptions import TelegramBadRequest +from aiogram.filters.callback_data import CallbackData +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import CallbackQuery + +from config import TOTAL_GB +from database import delete_user_data, get_client_id_by_email, get_servers_from_db, restore_trial, update_key_expiry +from filters.admin import IsAdminFilter +from handlers.keys.key_utils import ( + delete_key_from_cluster, + delete_key_from_db, + renew_key_in_cluster, update_subscription, +) +from handlers.utils import sanitize_key_name +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb +from keyboards.admin.users_kb import build_user_edit_kb, build_key_edit_kb, build_key_delete_kb, \ + build_user_delete_kb, AdminUserEditorCallback, build_editor_kb, build_users_balance_kb, \ + build_users_balance_change_kb, build_user_key_kb, build_users_key_expiry_kb, AdminUserKeyEditorCallback, \ + build_users_key_show_kb +from logger import logger + +router = Router() + + +class UserEditorState(StatesGroup): + # search + waiting_for_user_data = State() + waiting_for_key_name = State() + # updating data + waiting_for_balance = State() + waiting_for_expiry_time = State() + waiting_for_message_text = State() + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "search_user"), + IsAdminFilter(), +) +async def handle_search_user( + callback_query: CallbackQuery, + state: FSMContext +): + text = ( + "🔍 Поиск пользователя" + "\n\n📌 Введите ID, Username или перешлите сообщение пользователя." + "\n\n🆔 ID - числовой айди" + "\n📝 Username - юзернейм пользователя" + "\n\n✉️ Для поиска, вы можете просто переслать сообщение от пользователя." + ) + + await state.set_state(UserEditorState.waiting_for_user_data) + await callback_query.message.edit_text( + text=text, + reply_markup=build_admin_back_kb() + ) + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "search_key"), + IsAdminFilter(), +) +async def handle_search_key( + callback_query: CallbackQuery, + state: FSMContext +): + await state.set_state(UserEditorState.waiting_for_key_name) + await callback_query.message.edit_text( + text="🔑 Введите имя ключа для поиска:", + reply_markup=build_admin_back_kb() + ) + + +@router.message( + UserEditorState.waiting_for_user_data, + IsAdminFilter() +) +async def handle_user_data_input( + message: types.Message, + state: FSMContext, + session: Any +): + kb = build_admin_back_kb() + + if message.forward_from: + tg_id = message.forward_from.id + await process_user_search(message, state, session, tg_id) + return + + if not message.text: + await message.answer( + text="🚫 Пожалуйста, отправьте текстовое сообщение.", + reply_markup=kb + ) + return + + if message.text.isdigit(): + tg_id = int(message.text) + else: + # Удаление '@' символа в начале сообщения + username = message.text.strip().lstrip('@') + # Удаление начала ссылки на профиль + username = username.replace('https://t.me/', '') + + user = await session.fetchrow( + "SELECT tg_id FROM users WHERE username = $1", username + ) + + if not user: + await message.answer( + text="🚫 Пользователь с указанным Username не найден!", + reply_markup=kb, + ) + return + + tg_id = user["tg_id"] + + await process_user_search(message, state, session, tg_id) + + +@router.message( + UserEditorState.waiting_for_key_name, + IsAdminFilter() +) +async def handle_key_name_input( + message: types.Message, + state: FSMContext, + session: Any +): + kb = build_admin_back_kb() + + if not message.text: + await message.answer( + text="🚫 Пожалуйста, отправьте текстовое сообщение.", + reply_markup=kb + ) + return + + key_name = sanitize_key_name(message.text) + key_details = await get_key_details(key_name, session) + + if not key_details: + await message.answer( + text="🚫 Пользователь с указанным именем ключа не найден.", + reply_markup=kb + ) + return + + await process_user_search(message, state, session, key_details["tg_id"]) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_send_message"), + IsAdminFilter(), +) +async def handle_send_message( + callback_query: types.CallbackQuery, + callback_data: AdminUserEditorCallback, + state: FSMContext +): + tg_id = callback_data.tg_id + + await callback_query.message.edit_text( + text="✉️ Введите текст сообщения, которое вы хотите отправить пользователю:", + reply_markup=build_editor_kb(tg_id) + ) + + await state.update_data(tg_id=tg_id) + await state.set_state(UserEditorState.waiting_for_message_text) + + +@router.message( + UserEditorState.waiting_for_message_text, + IsAdminFilter() +) +async def handle_message_text_input( + message: types.Message, + state: FSMContext +): + data = await state.get_data() + tg_id = data.get("tg_id") + + try: + await message.bot.send_message( + chat_id=tg_id, + text=message.text + ) + await message.answer( + text="✅ Сообщение успешно отправлено.", + reply_markup=build_editor_kb(tg_id) + ) + except Exception as e: + await message.answer( + text=f"❌ Не удалось отправить сообщение: {e}", + reply_markup=build_editor_kb(tg_id) + ) + + await state.clear() + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_trial_restore"), + IsAdminFilter(), +) +async def handle_trial_restore( + callback_query: types.CallbackQuery, + callback_data: AdminUserEditorCallback, + session: Any +): + tg_id = callback_data.tg_id + + await restore_trial(tg_id, session) + await callback_query.message.edit_text( + text="✅ Триал успешно восстановлен!", + reply_markup=build_editor_kb(tg_id) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_balance_edit"), + IsAdminFilter() +) +async def handle_balance_change( + callback_query: CallbackQuery, + callback_data: AdminUserEditorCallback, + session: Any +): + tg_id = callback_data.tg_id + + records = await session.fetch(""" + SELECT amount, payment_system, status, created_at + FROM payments + WHERE tg_id = $1 + ORDER BY created_at DESC + LIMIT 5 + """, tg_id) + + balance = await get_user_balance(tg_id, session) + + text = ( + f"💵 Изменение баланса" + f"\n\n🆔 ID: {tg_id}" + f"\n💰 Баланс: {balance}Р" + f"\n📊 Последние операции (5):" + ) + + if records: + for record in records: + amount = record["amount"] + payment_system = record["payment_system"] + status = record["status"] + date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S") + text += ( + f"\n
💸 Сумма: {amount} | {payment_system}" + f"\n📌 Статус: {status}" + f"\n⏳ Дата: {date}
" + ) + else: + text += "\n 🚫 Отсутствуют" + + await callback_query.message.edit_text( + text=text, + reply_markup=build_users_balance_kb(tg_id) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_balance_add"), + IsAdminFilter() +) +async def handle_balance_add( + callback_query: CallbackQuery, + callback_data: AdminUserEditorCallback, + state: FSMContext, + session: Any +): + tg_id = callback_data.tg_id + amount = callback_data.data + + if amount: + await add_user_balance(tg_id, int(amount), session) + await handle_balance_change(callback_query, callback_data, session) + return + + await state.update_data(tg_id=tg_id, op_type="add") + await state.set_state(UserEditorState.waiting_for_balance) + + await callback_query.message.edit_text( + text="✍️ Введите сумму, которую хотите добавить на баланс пользователя:", + reply_markup=build_users_balance_change_kb(tg_id) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_balance_take"), + IsAdminFilter() +) +async def handle_balance_take( + callback_query: CallbackQuery, + callback_data: AdminUserEditorCallback, + state: FSMContext +): + tg_id = callback_data.tg_id + + await state.update_data(tg_id=tg_id, op_type="take") + await state.set_state(UserEditorState.waiting_for_balance) + + await callback_query.message.edit_text( + text="✍️ Введите сумму, которую хотите вычесть из баланса пользователя:", + reply_markup=build_users_balance_change_kb(tg_id) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_balance_set"), + IsAdminFilter() +) +async def handle_balance_set( + callback_query: CallbackQuery, + callback_data: AdminUserEditorCallback, + state: FSMContext +): + tg_id = callback_data.tg_id + + await state.update_data(tg_id=tg_id, op_type="set") + await state.set_state(UserEditorState.waiting_for_balance) + + await callback_query.message.edit_text( + text="✍️ Введите баланс, который хотите установить пользователю:", + reply_markup=build_users_balance_change_kb(tg_id) + ) + + +@router.message( + UserEditorState.waiting_for_balance, + IsAdminFilter() +) +async def handle_balance_input( + message: types.Message, + state: FSMContext, + session: Any +): + data = await state.get_data() + tg_id = data.get("tg_id") + op_type = data.get("op_type") + + if not message.text.isdigit() or int(message.text) < 0: + await message.answer( + text="🚫 Пожалуйста, введите корректную сумму!", + reply_markup=build_users_balance_change_kb(tg_id) + ) + return + + amount = int(message.text) + + if op_type == "add": + text = f"✅ К балансу пользователя добавлено {amount}Р" + await add_user_balance(tg_id, amount, session) + elif op_type == "take": + text = f"✅ Из баланса пользователя было вычтено {amount}Р" + await add_user_balance(tg_id, -amount, session) + else: + text = f"✅ Баланс пользователя изменен на {amount}Р" + await set_user_balance(tg_id, amount, session) + + await message.answer( + text=text, + reply_markup=build_users_balance_change_kb(tg_id) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_key_edit"), + IsAdminFilter() +) +async def handle_key_edit( + callback_query: CallbackQuery, + callback_data: CallbackData, + session: Any, + update: bool = False +): + email = callback_data.data + key_details = await get_key_details(email, session) + + if not key_details: + await callback_query.message.edit_text( + text="🚫 Информация о ключе не найдена.", + reply_markup=build_editor_kb(callback_data.tg_id), + ) + return + + text = ( + f"🔑 Информация о ключе" + f"\n\n{key_details['key']}" + f"\n\n⏰ Дата истечения: {key_details['expiry_date']} (UTC)" + f"\n🌐 Кластер: {key_details['cluster_name']}" + f"\n🆔 ID клиента: {key_details['tg_id']}" + ) + + if not update or not callback_data.edit: + await callback_query.message.edit_text( + text=text, + reply_markup=build_key_edit_kb(key_details, email) + ) + else: + await callback_query.message.edit_text( + text=text, + reply_markup=build_users_key_expiry_kb(callback_data.tg_id, email) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_expiry_edit"), + IsAdminFilter() +) +async def handle_change_expiry( + callback_query: CallbackQuery, + callback_data: AdminUserEditorCallback +): + tg_id = callback_data.tg_id + email = callback_data.data + + await callback_query.message.edit_reply_markup( + reply_markup=build_users_key_expiry_kb(tg_id, email) + ) + + +@router.callback_query( + AdminUserKeyEditorCallback.filter(F.action == "add"), + IsAdminFilter() +) +async def handle_expiry_add( + callback_query: CallbackQuery, + callback_data: AdminUserKeyEditorCallback, + state: FSMContext, + session: Any +): + tg_id = callback_data.tg_id + email = callback_data.data + month = callback_data.month + + key_details = await get_key_details(email, session) + + if not key_details: + await callback_query.message.edit_text( + text="🚫 Информация о ключе не найдена.", + reply_markup=build_editor_kb(tg_id), + ) + return + + if month: + await change_expiry_time(key_details["expiry_time"] + month * 30 * 24 * 3600 * 1000, email, session) + await handle_key_edit(callback_query, callback_data, session, True) + return + + await state.update_data(tg_id=tg_id, email=email, op_type="add") + await state.set_state(UserEditorState.waiting_for_expiry_time) + + await callback_query.message.edit_text( + text="✍️ Введите количество дней, которое хотите добавить к времени действия ключа:", + reply_markup=build_users_key_show_kb(tg_id, email) + ) + + +@router.callback_query( + AdminUserKeyEditorCallback.filter(F.action == "take"), + IsAdminFilter() +) +async def handle_expiry_take( + callback_query: CallbackQuery, + callback_data: AdminUserKeyEditorCallback, + state: FSMContext +): + tg_id = callback_data.tg_id + email = callback_data.data + + await state.update_data(tg_id=tg_id, email=email, op_type="take") + await state.set_state(UserEditorState.waiting_for_expiry_time) + + await callback_query.message.edit_text( + text="✍️ Введите количество дней, которое хотите вычесть из времени действия ключа:", + reply_markup=build_users_key_show_kb(tg_id, email) + ) + + +@router.callback_query( + AdminUserKeyEditorCallback.filter(F.action == "set"), + IsAdminFilter() +) +async def handle_expiry_set( + callback_query: CallbackQuery, + callback_data: AdminUserKeyEditorCallback, + state: FSMContext +): + tg_id = callback_data.tg_id + email = callback_data.data + + await state.update_data(tg_id=tg_id, email=email, op_type="set") + await state.set_state(UserEditorState.waiting_for_expiry_time) + + text = ( + "✍️ Введите новое время действия ключа:" + "\n\n📌 Формат: год-месяц-день час:минута" + "\n Пример: 2025-02-09 09:01" + ) + + await callback_query.message.edit_text( + text=text, + reply_markup=build_users_key_show_kb(tg_id, email) + ) + + +@router.message( + UserEditorState.waiting_for_expiry_time, + IsAdminFilter() +) +async def handle_expiry_time_input( + message: types.Message, + state: FSMContext, + session: Any +): + data = await state.get_data() + tg_id = data.get("tg_id") + email = data.get("email") + op_type = data.get("op_type") + + if op_type != "set" and (not message.text.isdigit() or int(message.text) < 0): + await message.answer( + text="🚫 Пожалуйста, введите корректное количество дней!", + reply_markup=build_users_key_show_kb(tg_id, email) + ) + return + + key_details = await get_key_details(email, session) + + if not key_details: + await message.answer( + text="🚫 Информация о ключе не найдена.", + reply_markup=build_editor_kb(tg_id), + ) + return + + if op_type == "add": + days = int(message.text) + text = f"✅ Ко времени действия ключа добавлено {days} дн." + await change_expiry_time(key_details["expiry_time"] + days * 24 * 3600 * 1000, email, session) + elif op_type == "take": + days = int(message.text) + text = f"✅ Из времени действия ключа вычтено {days} дн." + await change_expiry_time(key_details["expiry_time"] - days * 24 * 3600 * 1000, email, session) + else: + try: + expiry_time = int( + datetime.strptime(message.text, "%Y-%m-%d %H:%M").timestamp() * 1000 + ) + text = f"✅ Время действия ключа изменено на {message.text}" + await change_expiry_time(expiry_time, email, session) + except ValueError: + text = f"🚫 Пожалуйста, используйте корректный формат даты!" + except Exception as e: + text = f"❗ Произошла ошибка во время изменения времени действия ключа: {e}" + + await message.answer( + text=text, + reply_markup=build_users_key_show_kb(tg_id, email) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_update_key"), + IsAdminFilter() +) +async def handle_update_key( + callback_query: CallbackQuery, + callback_data: AdminUserEditorCallback, + session: Any +): + tg_id = callback_data.tg_id + email = callback_data.data + + try: + await update_subscription(tg_id, email, session) + await handle_key_edit(callback_query, callback_data, session) + except TelegramBadRequest: + pass + except Exception as e: + logger.error(f"Ошибка при обновлении ключа {email} администратором: {e}") + await callback_query.message.answer( + text=f"❗ Произошла ошибка при обновлении ключа: {e}", + reply_markup=build_user_key_kb(tg_id, email) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_delete_key"), + IsAdminFilter() +) +async def handle_delete_key( + callback_query: types.CallbackQuery, + callback_data: AdminUserEditorCallback, + session: Any +): + email = callback_data.data + client_id = await session.fetchval( + "SELECT client_id FROM keys WHERE email = $1", email + ) + + if client_id is None: + await callback_query.message.edit_text( + text="🚫 Ключ не найден!", + reply_markup=build_editor_kb(callback_data.tg_id) + ) + return + + await callback_query.message.edit_text( + text="❓ Вы уверены, что хотите удалить ключ?", + reply_markup=build_key_delete_kb(callback_data.tg_id, email) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_delete_key_confirm"), + IsAdminFilter() +) +async def handle_delete_key_confirm( + callback_query: types.CallbackQuery, + callback_data: AdminUserEditorCallback, + session: Any +): + email = callback_data.data + record = await session.fetchrow( + "SELECT client_id FROM keys WHERE email = $1", email + ) + + kb = build_editor_kb(callback_data.tg_id) + + if record: + client_id = record["client_id"] + clusters = await get_servers_from_db() + + async def delete_key_from_servers(): + tasks = [] + for cluster_name, cluster_servers in clusters.items(): + for _ in cluster_servers: + tasks.append( + delete_key_from_cluster(cluster_name, email, client_id) + ) + await asyncio.gather(*tasks) + + await delete_key_from_servers() + await delete_key_from_db(client_id, session) + + await callback_query.message.edit_text( + text="✅ Ключ успешно удален.", + reply_markup=kb + ) + else: + await callback_query.message.edit_text( + text="🚫 Ключ не найден или уже удален.", + reply_markup=kb + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_delete_user"), + IsAdminFilter() +) +async def handle_delete_user( + callback_query: types.CallbackQuery, + callback_data: AdminUserEditorCallback +): + tg_id = callback_data.tg_id + await callback_query.message.edit_text( + text=f"❗️ Вы уверены, что хотите удалить пользователя с ID {tg_id}?", + reply_markup=build_user_delete_kb(tg_id) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_delete_user_confirm"), + IsAdminFilter() +) +async def handle_delete_user_confirm( + callback_query: types.CallbackQuery, + callback_data: AdminUserEditorCallback, + session: Any +): + tg_id = callback_data.tg_id + key_records = await session.fetch("SELECT email, client_id FROM keys WHERE tg_id = $1", tg_id) + + async def delete_keys_from_servers(): + try: + tasks = [] + for email, client_id in key_records: + servers = await get_servers_from_db() + for cluster_id, cluster in servers.items(): + tasks.append(delete_key_from_cluster(cluster_id, email, client_id)) + await asyncio.gather(*tasks) + except Exception as e: + logger.error(f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}") + + await delete_keys_from_servers() + + try: + await delete_user_data(session, tg_id) + await callback_query.message.edit_text( + text=f"🗑️ Пользователь с ID {tg_id} был удален.", + reply_markup=build_editor_kb(callback_data.tg_id) + ) + except Exception as e: + logger.error(f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}") + await callback_query.message.edit_text( + text=f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова." + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_editor"), + IsAdminFilter() +) +async def handle_editor( + callback_query: types.CallbackQuery, + callback_data: AdminUserEditorCallback, + state: FSMContext, + session: Any +): + await process_user_search( + callback_query.message, + state, + session, + callback_data.tg_id, + callback_data.edit + ) + + +async def process_user_search( + message: types.Message, + state: FSMContext, + session: Any, + tg_id: int, + edit: bool = False +) -> None: + await state.clear() + + balance = await session.fetchval( + "SELECT balance FROM connections WHERE tg_id = $1", tg_id + ) + + if balance is None: + await message.answer( + text="🚫 Пользователь с указанным ID не найден!", + reply_markup=build_admin_back_kb(), + ) + return + + username = await session.fetchval( + "SELECT username FROM users WHERE tg_id = $1", tg_id + ) + key_records = await session.fetch( + "SELECT email, expiry_time FROM keys WHERE tg_id = $1", tg_id + ) + referral_count = await session.fetchval( + "SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id + ) + + text = ( + f"📊 Информация о пользователе" + f"\n\n🆔 ID: {tg_id}" + f"\n📄 Логин: @{username}" + f"\n💰 Баланс: {balance}" + f"\n👥 Количество рефералов: {referral_count}" + ) + + kb = build_user_edit_kb(tg_id, key_records) + + if edit: + try: + await message.edit_text( + text=text, + reply_markup=kb + ) + except TelegramBadRequest: + pass + else: + await message.answer( + text=text, + reply_markup=kb + ) + + +async def get_key_details(email, session): + record = await session.fetchrow( + """ + SELECT k.client_id, k.key, k.expiry_time, k.server_id, c.tg_id, c.balance + FROM keys k + JOIN connections c ON k.tg_id = c.tg_id + WHERE k.email = $1 + """, + email, + ) + + if not record: + return None + + cluster_name = record["server_id"] + expiry_date = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=timezone.utc) + + return { + "client_id": record["client_id"], + "balance": record["balance"], + "tg_id": record["tg_id"], + "key": record["key"], + "cluster_name": cluster_name, + "expiry_time": record["expiry_time"], + "expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"), + } + + +async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exception | None: + client_id = await get_client_id_by_email(email) + + if client_id is None: + return ValueError(f"User with email {email} was not found") + + server_id = await session.fetchrow( + "SELECT server_id FROM keys WHERE client_id = $1", client_id + ) + + if not server_id: + return ValueError(f"User with client_id {server_id} was not found") + + clusters = await get_servers_from_db() + + async def update_key_on_all_servers(): + tasks = [ + asyncio.create_task( + renew_key_in_cluster( + cluster_name, + email, + client_id, + expiry_time, + total_gb=TOTAL_GB, + ) + ) + for cluster_name in clusters + ] + + await asyncio.gather(*tasks) + + await update_key_on_all_servers() + await update_key_expiry(client_id, expiry_time) + + +async def get_user_balance(tg_id: int, session: Any) -> float: + try: + return await session.fetchval( + "SELECT balance FROM connections WHERE tg_id = $1", tg_id, + ) + except Exception as e: + logger.error(f"Ошибка при получении баланса для пользователя {tg_id}: {e}") + return -1 + + +async def add_user_balance(tg_id: int, balance: int, session: Any) -> None: + try: + await session.execute( + "UPDATE connections SET balance = balance + $1 WHERE tg_id = $2", + balance, tg_id, + ) + except Exception as e: + logger.error(f"Ошибка при добавлении баланса для пользователя {tg_id}: {e}") + + +async def set_user_balance(tg_id: int, balance: int, session: Any) -> None: + try: + await session.execute( + "UPDATE connections SET balance = $1 WHERE tg_id = $2", + balance, tg_id, + ) + except Exception as e: + logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}") diff --git a/handlers/keys/key_utils.py b/handlers/keys/key_utils.py index ffc42ad9..c99f84b5 100644 --- a/handlers/keys/key_utils.py +++ b/handlers/keys/key_utils.py @@ -1,10 +1,12 @@ import asyncio +from typing import Any from py3xui import AsyncApi from client import add_client, delete_client, extend_client_key -from config import ADMIN_PASSWORD, ADMIN_USERNAME, LIMIT_IP, SUPERNODE, TOTAL_GB -from database import get_servers_from_db +from config import ADMIN_PASSWORD, ADMIN_USERNAME, LIMIT_IP, SUPERNODE, TOTAL_GB, PUBLIC_LINK +from database import get_servers_from_db, store_key +from handlers.utils import get_least_loaded_cluster from logger import logger @@ -102,8 +104,6 @@ async def create_client_on_server( if SUPERNODE: await asyncio.sleep(0.7) - - async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb): try: servers = await get_servers_from_db() @@ -256,3 +256,53 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id f"Ошибка при обновлении ключа на серверах кластера {cluster_id} для {client_id}: {e}" ) raise e + + +async def update_subscription(tg_id: int, email: str, session: Any) -> None: + record = await session.fetchrow( + """ + SELECT k.key, k.expiry_time, k.email, k.server_id, k.client_id + FROM keys k + WHERE k.tg_id = $1 AND k.email = $2 + """, + tg_id, + email, + ) + + if not record: + raise ValueError(f"The key {email} does not exist in database") + + expiry_time = record["expiry_time"] + client_id = record["client_id"] + public_link = f"{PUBLIC_LINK}{email}/{tg_id}" + + await session.execute( + """ + DELETE FROM keys + WHERE tg_id = $1 AND email = $2 + """, + tg_id, + email, + ) + + least_loaded_cluster_id = await get_least_loaded_cluster() + + await asyncio.gather( + update_key_on_cluster( + tg_id, + client_id, + email, + expiry_time, + least_loaded_cluster_id, + ) + ) + + await store_key( + tg_id, + client_id, + email, + expiry_time, + public_link, + server_id=least_loaded_cluster_id, + session=session, + ) diff --git a/handlers/keys/keys.py b/handlers/keys/keys.py index 28c0bbc1..b62466c7 100644 --- a/handlers/keys/keys.py +++ b/handlers/keys/keys.py @@ -9,6 +9,7 @@ import pytz from aiogram import F, Router, types from aiogram.types import BufferedInputFile, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder +from handlers.payments.yookassa_pay import process_custom_amount_input from bot import bot from config import ( @@ -30,7 +31,6 @@ from database import ( get_balance, get_servers_from_db, save_temporary_data, - store_key, update_balance, update_key_expiry, ) @@ -46,10 +46,9 @@ from handlers.keys.key_utils import ( delete_key_from_cluster, delete_key_from_db, renew_key_in_cluster, - update_key_on_cluster, + update_subscription, ) from handlers.payments.robokassa_pay import handle_custom_amount_input -from handlers.payments.yookassa_pay import process_custom_amount_input from handlers.texts import ( DISCOUNTS, KEY_NOT_FOUND_MSG, @@ -57,7 +56,7 @@ from handlers.texts import ( SUCCESS_RENEWAL_MSG, key_message, ) -from handlers.utils import get_least_loaded_cluster, handle_error +from handlers.utils import handle_error from logger import logger locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8") @@ -68,7 +67,7 @@ router = Router() @router.callback_query(F.data == "view_keys") @router.message(F.text == "/subs") async def process_callback_or_message_view_keys( - callback_query_or_message: types.Message | types.CallbackQuery, session: Any + callback_query_or_message: types.Message | types.CallbackQuery, session: Any ): if isinstance(callback_query_or_message, types.CallbackQuery): chat_id = callback_query_or_message.message.chat.id @@ -150,7 +149,7 @@ def build_keys_response(records): async def send_with_optional_image( - send_message, send_photo, image_path, text, keyboard + send_message, send_photo, image_path, text, keyboard ): """ Отправляет сообщение с изображением, если файл существует. В противном случае отправляет только текст. @@ -293,67 +292,16 @@ async def process_callback_view_key(callback_query: types.CallbackQuery, session @router.callback_query(F.data.startswith("update_subscription|")) async def process_callback_update_subscription( - callback_query: types.CallbackQuery, session: Any + callback_query: types.CallbackQuery, session: Any ): tg_id = callback_query.message.chat.id email = callback_query.data.split("|")[1] + try: - record = await session.fetchrow( - """ - SELECT k.key, k.expiry_time, k.email, k.server_id, k.client_id - FROM keys k - WHERE k.tg_id = $1 AND k.email = $2 - """, - tg_id, - email, - ) - - if record: - expiry_time = record["expiry_time"] - client_id = record["client_id"] - public_link = f"{PUBLIC_LINK}{email}/{tg_id}" - - try: - await session.execute( - """ - DELETE FROM keys - WHERE tg_id = $1 AND email = $2 - """, - tg_id, - email, - ) - except Exception as delete_error: - await callback_query.message.answer( - f"Ошибка при удалении старой подписки: {delete_error}", - ) - return - - least_loaded_cluster_id = await get_least_loaded_cluster() - - await asyncio.gather( - update_key_on_cluster( - tg_id, - client_id, - email, - expiry_time, - least_loaded_cluster_id, - ) - ) - - await store_key( - tg_id, - client_id, - email, - expiry_time, - public_link, - server_id=least_loaded_cluster_id, - session=session, - ) - - await process_callback_view_key(callback_query, session) - else: - await callback_query.message.answer("Ключ не найден в базе данных.") + await update_subscription(tg_id, email, session) + await process_callback_view_key(callback_query, session) except Exception as e: + logger.error(f"Ошибка при обновлении ключа {email} пользователем: {e}") await handle_error( tg_id, callback_query, f"Ошибка при обновлении подписки: {e}" ) @@ -450,7 +398,7 @@ async def process_callback_renew_key(callback_query: types.CallbackQuery, sessio @router.callback_query(F.data.startswith("confirm_delete|")) async def process_callback_confirm_delete( - callback_query: types.CallbackQuery, session: Any + callback_query: types.CallbackQuery, session: Any ): email = callback_query.data.split("|")[1] try: @@ -537,7 +485,8 @@ async def process_callback_renew_plan(callback_query: types.CallbackQuery, sessi if balance < cost: required_amount = cost - balance - logger.info(f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}") + logger.info( + f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}") await save_temporary_data( session, diff --git a/keyboards/__init__.py b/keyboards/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/keyboards/admin/__init__.py b/keyboards/admin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/keyboards/admin/bans_kb.py b/keyboards/admin/bans_kb.py new file mode 100644 index 00000000..958d0a14 --- /dev/null +++ b/keyboards/admin/bans_kb.py @@ -0,0 +1,21 @@ +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from keyboards.admin.panel_kb import build_admin_back_btn, AdminPanelCallback + + +def build_bans_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="📄 Выгрузить в CSV", + callback_data=AdminPanelCallback(action="bans_export").pack() + ) + builder.button( + text="🗑️ Удалить из БД", + callback_data=AdminPanelCallback(action="bans_delete_banned").pack() + ) + builder.row( + build_admin_back_btn("management") + ) + builder.adjust(1) + return builder.as_markup() diff --git a/keyboards/admin/coupons_kb.py b/keyboards/admin/coupons_kb.py new file mode 100644 index 00000000..41ebd8c3 --- /dev/null +++ b/keyboards/admin/coupons_kb.py @@ -0,0 +1,43 @@ +from aiogram.filters.callback_data import CallbackData +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_btn + + +class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"): + coupon_code: str + + +def build_coupons_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="➕ Создать купон", + callback_data=AdminPanelCallback(action="coupons_create").pack() + ) + builder.button( + text="Купоны", + callback_data=AdminPanelCallback(action="coupons_list").pack() + ) + builder.row( + build_admin_back_btn() + ) + return builder.as_markup() + + +def build_coupons_list_kb(coupons: list) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + for coupon in coupons: + coupon_code = coupon["code"] + builder.button( + text=f"❌ Удалить {coupon_code}", + callback_data=AdminCouponDeleteCallback( + coupon_code=coupon_code + ).pack(), + ) + + builder.row( + build_admin_back_btn("coupons") + ) + return builder.as_markup() diff --git a/keyboards/admin/panel_kb.py b/keyboards/admin/panel_kb.py new file mode 100644 index 00000000..9b85a216 --- /dev/null +++ b/keyboards/admin/panel_kb.py @@ -0,0 +1,103 @@ +from aiogram.filters.callback_data import CallbackData +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder + + +class AdminPanelCallback(CallbackData, prefix="admin_panel"): + action: str + + +def build_panel_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="👤 Поиск пользователя", + callback_data=AdminPanelCallback(action="search_user").pack() + ) + builder.button( + text="🔑 Поиск по названию ключа", + callback_data=AdminPanelCallback(action="search_key").pack() + ) + builder.row( + InlineKeyboardButton( + text="🖥️ Серверы", + callback_data=AdminPanelCallback(action="servers").pack() + ), + InlineKeyboardButton( + text="🎟️ Купоны", + callback_data=AdminPanelCallback(action="coupons").pack() + ) + ) + builder.button( + text="📢 Рассылка", + callback_data=AdminPanelCallback(action="sender").pack() + ) + builder.row( + InlineKeyboardButton( + text="📊 Статистика", + callback_data=AdminPanelCallback(action="stats").pack() + ), + InlineKeyboardButton( + text="🤖 Управление", + callback_data=AdminPanelCallback(action="management").pack() + ) + ) + builder.button( + text="Личный кабинет", + callback_data="profile" + ) + builder.adjust(1, 1, 2, 1, 2, 1) + return builder.as_markup() + + +def build_management_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="💾 Создать резервную копию", + callback_data=AdminPanelCallback(action="backups").pack() + ) + builder.button( + text="🚫 Заблокировавшие бота", + callback_data=AdminPanelCallback(action="bans").pack() + ) + builder.button( + text="🔄 Перезагрузить бота", + callback_data=AdminPanelCallback(action="restart").pack() + ) + builder.row( + build_admin_back_btn() + ) + builder.adjust(1) + return builder.as_markup() + + +def build_restart_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="✅ Да, перезагрузить", + callback_data=AdminPanelCallback(action="restart_confirm").pack() + ) + builder.row( + build_admin_back_btn() + ) + builder.adjust(1) + return builder.as_markup() + + +def build_admin_back_kb(action: str = "admin") -> InlineKeyboardMarkup: + return build_admin_singleton_kb("🔙 Назад", action) + + +def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.row( + build_admin_btn(text, action) + ) + return builder.as_markup() + + +def build_admin_back_btn(action: str = "admin") -> InlineKeyboardMarkup: + return build_admin_btn("🔙 Назад", action) + + +def build_admin_btn(text: str, action: str) -> InlineKeyboardButton: + return InlineKeyboardButton(text=text, callback_data=AdminPanelCallback(action=action).pack()) diff --git a/keyboards/admin/sender_kb.py b/keyboards/admin/sender_kb.py new file mode 100644 index 00000000..bfe9ad39 --- /dev/null +++ b/keyboards/admin/sender_kb.py @@ -0,0 +1,36 @@ +from aiogram.filters.callback_data import CallbackData +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from keyboards.admin.panel_kb import build_admin_back_btn + + +class AdminSenderCallback(CallbackData, prefix="admin_sender"): + type: str + + +def build_sender_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="👥 Все пользователи", + callback_data=AdminSenderCallback( + type="all" + ).pack() + ) + builder.button( + text="✅ Пользователи с подпиской", + callback_data=AdminSenderCallback( + type="subscribed" + ).pack() + ) + builder.button( + text="❌ Пользователи без подписки", + callback_data=AdminSenderCallback( + type="unsubscribed" + ).pack() + ) + builder.row( + build_admin_back_btn() + ) + builder.adjust(1) + return builder.as_markup() diff --git a/keyboards/admin/servers_kb.py b/keyboards/admin/servers_kb.py new file mode 100644 index 00000000..d7dd239c --- /dev/null +++ b/keyboards/admin/servers_kb.py @@ -0,0 +1,113 @@ +from aiogram.filters.callback_data import CallbackData +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_btn + + +class AdminServerEditorCallback(CallbackData, prefix="admin_servers"): + action: str + data: str + + +def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + for cluster_name in servers: + builder.button( + text=f"⚙️ {cluster_name}", + callback_data=AdminServerEditorCallback( + action="clusters_manage", + data=cluster_name + ).pack() + ) + + builder.button( + text="➕ Добавить кластер", + callback_data=AdminPanelCallback(action="clusters_add").pack() + ) + builder.row( + build_admin_back_btn() + ) + builder.adjust(1) + return builder.as_markup() + + +def build_manage_cluster_kb(cluster_servers, cluster_name) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + for server in cluster_servers: + builder.button( + text=f"🌍 {server['server_name']}", + callback_data=AdminServerEditorCallback( + action="servers_manage", + data=server["server_name"] + ).pack() + ) + + builder.button( + text="➕ Добавить сервер", + callback_data=AdminServerEditorCallback( + action="servers_add", + data=cluster_name + ).pack() + ) + builder.button( + text="🌐 Доступность серверов", + callback_data=AdminServerEditorCallback( + action="servers_availability", + data=cluster_name + ).pack() + ) + builder.button( + text="💾 Создать бэкап кластера", + callback_data=AdminServerEditorCallback( + action="clusters_backup", + data=cluster_name + ).pack() + ) + builder.row( + build_admin_back_btn("servers") + ) + builder.adjust(1) + return builder.as_markup() + + +def build_manage_server_kb(server_name: str, cluster_name: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="🗑️ Удалить", + callback_data=AdminServerEditorCallback( + action="servers_delete", + data=server_name + ).pack() + ) + builder.button( + text="🔙 Назад", + callback_data=AdminServerEditorCallback( + action="clusters_manage", + data=cluster_name + ).pack() + ) + builder.adjust(1) + return builder.as_markup() + + +def build_delete_server_kb(server_name: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="✅ Да", + callback_data=AdminServerEditorCallback( + action="servers_delete_confirm", + data=server_name + ).pack() + ) + builder.button( + text="🔙 Назад", + callback_data=AdminServerEditorCallback( + action="servers_manage", + data=server_name + ).pack() + ) + builder.adjust(1) + return builder.as_markup() diff --git a/keyboards/admin/stats_kb.py b/keyboards/admin/stats_kb.py new file mode 100644 index 00000000..4e1db626 --- /dev/null +++ b/keyboards/admin/stats_kb.py @@ -0,0 +1,25 @@ +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from keyboards.admin.panel_kb import build_admin_back_btn, AdminPanelCallback + + +def build_stats_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="🔄 Обновить", + callback_data=AdminPanelCallback(action="stats").pack() + ) + builder.button( + text="📥 Выгрузить пользователей в CSV", + callback_data=AdminPanelCallback(action="stats_export_users_csv").pack() + ) + builder.button( + text="📥 Выгрузить оплаты в CSV", + callback_data=AdminPanelCallback(action="stats_export_payments_csv").pack() + ) + builder.row( + build_admin_back_btn() + ) + builder.adjust(1) + return builder.as_markup() diff --git a/keyboards/admin/users_kb.py b/keyboards/admin/users_kb.py new file mode 100644 index 00000000..6eb8537c --- /dev/null +++ b/keyboards/admin/users_kb.py @@ -0,0 +1,317 @@ +from datetime import datetime, timezone + +from aiogram.filters.callback_data import CallbackData +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from config import RENEWAL_PRICES +from keyboards.admin.panel_kb import build_admin_back_btn + + +class AdminUserEditorCallback(CallbackData, prefix="admin_users"): + action: str + tg_id: int + data: str | int | None = None + edit: bool = False + + +class AdminUserKeyEditorCallback(CallbackData, prefix="admin_users_key"): + action: str + tg_id: int + data: str + month: int | None = None + + +def build_user_edit_kb(tg_id: int, key_records: list) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + current_time = datetime.now(tz=timezone.utc) + + for record in key_records: + email = record["email"] + expiry = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=timezone.utc) + days = (expiry - current_time).days + builder.button( + text=f"🔑 {email} ({'<1' if days < 1 else days} дн.)", + callback_data=AdminUserEditorCallback( + action="users_key_edit", + tg_id=tg_id, + data=str(email) + ).pack() + ) + + builder.button( + text="✉️ Сообщение", + callback_data=AdminUserEditorCallback( + action="users_send_message", + tg_id=tg_id + ).pack() + ) + builder.button( + text="💸 Изменить баланс", + callback_data=AdminUserEditorCallback( + action="users_balance_edit", + tg_id=tg_id + ).pack() + ) + builder.button( + text="♻️ Восстановить триал", + callback_data=AdminUserEditorCallback( + action="users_trial_restore", + tg_id=tg_id + ).pack() + ) + builder.button( + text="❌ Удалить клиента", + callback_data=AdminUserEditorCallback( + action="users_delete_user", + tg_id=tg_id + ).pack() + ) + builder.row( + build_editor_btn("🔄 Обновить данные", tg_id, edit=True) + ) + builder.row( + build_admin_back_btn() + ) + builder.adjust(1) + return builder.as_markup() + + +def build_users_balance_change_kb(tg_id: int) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="🔙 Назад", # todo: fix magic text was set + callback_data=AdminUserEditorCallback( + action="users_balance_edit", + tg_id=tg_id + ).pack() + ) + return builder.as_markup() + + +def build_users_balance_kb(tg_id: int) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + for month, amount in RENEWAL_PRICES.items(): + builder.button( + text=f"+ {amount}Р ({month} мес.)", + callback_data=AdminUserEditorCallback( + action="users_balance_add", + tg_id=tg_id, + data=amount + ).pack() + ) + builder.button( + text=f"- {amount}Р ({month} мес.)", + callback_data=AdminUserEditorCallback( + action="users_balance_add", + tg_id=tg_id, + data=-amount + ).pack() + ) + builder.button( + text="💵 Добавить", + callback_data=AdminUserEditorCallback( + action="users_balance_add", + tg_id=tg_id + ).pack() + ) + builder.button( + text="💵 Вычесть", + callback_data=AdminUserEditorCallback( + action="users_balance_take", + tg_id=tg_id + ).pack() + ) + builder.button( + text="💵 Установить баланс", + callback_data=AdminUserEditorCallback( + action="users_balance_set", + tg_id=tg_id + ).pack() + ) + builder.row( + build_editor_back_btn(tg_id, True) + ) + builder.adjust(2, 2, 2, 2, 2, 1) + return builder.as_markup() + + +def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="🔙 Назад", # todo: fix magic text was set + callback_data=AdminUserEditorCallback( + action="users_key_edit", + tg_id=tg_id, + data=email, + edit=True + ).pack() + ) + return builder.as_markup() + + +def build_users_key_expiry_kb(tg_id: int, email: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + for month in RENEWAL_PRICES.keys(): + month = int(month) + builder.button( + text=f"+ {month} мес.", + callback_data=AdminUserKeyEditorCallback( + action="add", + tg_id=tg_id, + data=email, + month=month + ).pack() + ) + builder.button( + text=f"- {month} мес.", + callback_data=AdminUserKeyEditorCallback( + action="add", + tg_id=tg_id, + data=email, + month=-month + ).pack() + ) + builder.button( + text="⏳ Добавить дни", + callback_data=AdminUserKeyEditorCallback( + action="add", + tg_id=tg_id, + data=email + ).pack() + ) + builder.button( + text="⏳ Вычесть дни", + callback_data=AdminUserKeyEditorCallback( + action="take", + tg_id=tg_id, + data=email + ).pack() + ) + builder.button( + text="⏳ Установить дату истечения", + callback_data=AdminUserKeyEditorCallback( + action="set", + tg_id=tg_id, + data=email + ).pack() + ) + builder.button( + text="🔙 Назад", # todo: fix magic text was set + callback_data=AdminUserEditorCallback( + action="users_key_edit", + tg_id=tg_id, + data=email + ).pack() + ) + builder.adjust(2, 2, 2, 2, 2, 1) + return builder.as_markup() + + +def build_user_delete_kb(tg_id: int): + builder = InlineKeyboardBuilder() + builder.button( + text="❌ Да, удалить!", + callback_data=AdminUserEditorCallback( + action="users_delete_user_confirm", + tg_id=tg_id + ).pack() + ) + builder.row( + build_editor_back_btn(tg_id, True) + ) + builder.adjust(1) + return builder.as_markup() + + +def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text=f"🔙 Назад", + callback_data=AdminUserEditorCallback( + action="users_key_edit", + tg_id=tg_id, + data=email + ).pack() + ) + builder.adjust(1) + return builder.as_markup() + + +def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="⏳ Время истечения", + callback_data=AdminUserEditorCallback( + action="users_expiry_edit", + data=email, + tg_id=key_details["tg_id"] + ).pack() + ) + builder.button( + text="🔄 Перевыпустить", + callback_data=AdminUserEditorCallback( + action="users_update_key", + data=email, + tg_id=key_details["tg_id"] + ).pack() + ) + builder.button( + text="❌ Удалить", + callback_data=AdminUserEditorCallback( + action="users_delete_key", + data=email, + tg_id=key_details["tg_id"] + ).pack() + ) + builder.row( + build_editor_back_btn(key_details["tg_id"], True) + ) + builder.adjust(1) + return builder.as_markup() + + +def build_key_delete_kb(tg_id: int, email: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text="✅ Да, удалить", + callback_data=AdminUserEditorCallback( + action="users_delete_key_confirm", + data=email, + tg_id=tg_id + ).pack() + ) + ) + builder.row( + build_editor_back_btn(tg_id) + ) + builder.adjust(1) + return builder.as_markup() + + +def build_editor_kb(tg_id: int, edit: bool = False) -> InlineKeyboardMarkup: + return build_editor_singleton_kb("🔙 Назад", tg_id, edit) + + +def build_editor_singleton_kb(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.row( + build_editor_btn(text, tg_id, edit) + ) + return builder.as_markup() + + +def build_editor_back_btn(tg_id: int, edit: bool = False) -> InlineKeyboardButton: + return build_editor_btn("🔙 Назад", tg_id, edit) + + +def build_editor_btn(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardButton: + return InlineKeyboardButton( + text=text, + callback_data=AdminUserEditorCallback( + action="users_editor", + tg_id=tg_id, + edit=edit + ).pack() + ) diff --git a/middlewares/delete.py b/middlewares/delete.py index 846ed89a..29281507 100644 --- a/middlewares/delete.py +++ b/middlewares/delete.py @@ -4,25 +4,53 @@ from typing import Any from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, Message, TelegramObject +from keyboards.admin.coupons_kb import AdminCouponDeleteCallback +from keyboards.admin.panel_kb import AdminPanelCallback +from keyboards.admin.sender_kb import AdminSenderCallback +from keyboards.admin.servers_kb import AdminServerEditorCallback +from keyboards.admin.users_kb import AdminUserEditorCallback, AdminUserKeyEditorCallback + +pass_callbacks = [ + AdminPanelCallback, + AdminCouponDeleteCallback, + AdminSenderCallback, + AdminServerEditorCallback, + AdminUserEditorCallback, + AdminUserKeyEditorCallback, +] + class DeleteMessageMiddleware(BaseMiddleware): async def __call__( - self, - handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], - event: TelegramObject, - data: dict[str, Any], + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], ) -> Any: - if isinstance(event, (Message, CallbackQuery)): - if isinstance(event, Message): - if not event.text or not event.text.startswith("/start"): - try: - await event.bot.delete_message( - event.chat.id, event.message_id - 1 - ) - except Exception: - pass - await event.delete() - elif isinstance(event, CallbackQuery): - await event.answer() + if isinstance(event, Message): + if ( + not event.text + or not event.text.startswith("/start") + ): + try: + await event.bot.delete_message( + event.chat.id, event.message_id - 1 + ) + except Exception: + pass + await event.delete() + + if isinstance(event, CallbackQuery): + await event.answer() + + if not await self._check_callbacks(event): await event.message.delete() + return await handler(event, data) + + @staticmethod + async def _check_callbacks(event: CallbackQuery) -> bool: + for callback in pass_callbacks: + if await callback.filter()(event): + return True + return False diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utils/csv_export.py b/utils/csv_export.py new file mode 100644 index 00000000..08cc1c59 --- /dev/null +++ b/utils/csv_export.py @@ -0,0 +1,101 @@ +from io import StringIO +from typing import Any + +from aiogram.types import BufferedInputFile + + +async def export_users_csv(session: Any) -> BufferedInputFile: + # Получение данных о платежах пользователя + query = """ + SELECT + u.tg_id, + u.username, + u.first_name, + u.last_name, + u.language_code, + u.is_bot, + c.balance, + c.trial + FROM users u + LEFT JOIN connections c ON u.tg_id = c.tg_id + """ + + users = await session.fetch(query) + + buffer = StringIO() + buffer.write("tg_id,username,first_name,last_name,language_code,is_bot,balance,trial\n") + + # Запись данных + for user in users: + buffer.write( + f"{user['tg_id']},{user['username']},{user['first_name']},{user['last_name']}," + f"{user['language_code']},{user['is_bot']},{user['balance']},{user['trial']}\n" + ) + + # Перемещение указателя в начало для чтения + buffer.seek(0) + + return BufferedInputFile( + file=buffer.getvalue().encode("utf-8-sig"), + filename="users_export.csv" + ) + + +async def export_payments_csv(session: Any) -> BufferedInputFile: + # Получение данных о всех платежах + query = """ + SELECT + u.tg_id, + u.username, + u.first_name, + u.last_name, + p.amount, + p.payment_system, + p.status, + p.created_at + FROM users u + JOIN payments p ON u.tg_id = p.tg_id + """ + payments = await session.fetch(query) + return _export_payments_csv(payments, "payments_export.csv") + + +async def export_user_payments_csv(tg_id: int, session: Any) -> BufferedInputFile: + # Получение данных о платежах пользователя + query = """ + SELECT + u.tg_id, + u.username, + u.first_name, + u.last_name, + p.amount, + p.payment_system, + p.status, + p.created_at + FROM users u + JOIN payments p ON u.tg_id = p.tg_id + WHERE u.tg_id = $1 + """ + payments = await session.fetch(query, tg_id) + return _export_payments_csv(payments, f"payments_export_{tg_id}.csv") + + +def _export_payments_csv(payments: list, filename: str) -> BufferedInputFile: + # Формирование CSV данных через StringIO + buffer = StringIO() + buffer.write("tg_id,username,first_name,last_name,amount,payment_system,status,created_at\n") + + # Запись данных + for payment in payments: + buffer.write( + f"{payment['tg_id']},{payment['username']},{payment['first_name']},{payment['last_name']}," + f"{payment['amount']},{payment['payment_system']},{payment['status']},{payment['created_at']}\n" + ) + + # Перемещение указателя в начало для чтения + buffer.seek(0) + + return BufferedInputFile( + file=buffer.getvalue().encode("utf-8-sig"), + filename=filename + )