diff --git a/backup.py b/backup.py index ca8f97f8..708595c5 100644 --- a/backup.py +++ b/backup.py @@ -9,73 +9,64 @@ 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") +def _create_database_backup() -> (str | None, Exception | None): + date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S") - if not os.path.exists(BACKUP_DIR): - os.makedirs(BACKUP_DIR) + if not os.path.exists(BACK_DIR): + os.makedirs(BACK_DIR) - BACKUP_FILE = f"{BACKUP_DIR}/{DB_NAME}-backup-{DATE}.sql" - - os.environ["PGPASSWORD"] = DB_PASSWORD + 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: - async with aiofiles.open(backup_file_path, "rb") as backup_file: - backup_file_data = await backup_file.read() - backup_input_file = BufferedInputFile(backup_file_data, 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}") - except Exception as e: - logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}") - - -def _cleanup_old_backups(): +def _cleanup_old_backups() -> None | Exception: try: subprocess.run( [ @@ -95,12 +86,30 @@ def _cleanup_old_backups(): check=True, ) logger.info("Старые бэкапы удалены.") + return None except subprocess.CalledProcessError as e: - logger.error(f"Ошибка при удалении старых бэкапов: {e}") - except Exception 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 006efeb4..027d2604 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)" + register_middleware(dp) dp.message.filter(IsPrivateFilter()) 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 8a991ec2..ba930fa0 100644 --- a/handlers/admin/admin_coupons.py +++ b/handlers/admin/admin_coupons.py @@ -3,33 +3,124 @@ 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, get_all_coupons +from database import delete_coupon, 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( + AdminPanelCallback.filter(F.action == "coupons"), + IsAdminFilter(), +) +async def handle_coupons( + callback_query: types.CallbackQuery, +): + await callback_query.message.edit_text( + text="🛠 Меню управления купонами:", + reply_markup=build_coupons_kb() + ) -@router.callback_query(F.data == "coupons_editor", IsAdminFilter()) -async def show_coupon_management_menu(callback_query: types.CallbackQuery, state: FSMContext): - 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()) +@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.callback_query(F.data.startswith("coupons"), IsAdminFilter()) -async def show_coupon_list(callback_query: types.CallbackQuery, session: Any): +@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: page = int(callback_query.data.split(":")[1]) if ":" in callback_query.data else 1 per_page = 10 @@ -39,16 +130,14 @@ async def show_coupon_list(callback_query: types.CallbackQuery, session: Any): current_page = result["current_page"] 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 - coupon_list = f"📜 Список купонов (страница {current_page} из {total_pages}):\n\n" - builder = InlineKeyboardBuilder() + kb = build_coupons_list_kb(coupons) + coupon_list = "📜 Список всех купонов:\n\n" for coupon in coupons: coupon_list += ( @@ -57,102 +146,38 @@ async def show_coupon_list(callback_query: types.CallbackQuery, session: Any): f"🔢 Лимит использования: {coupon['usage_limit']} раз\n" f"✅ Использовано: {coupon['usage_count']} раз\n\n" ) - builder.row( - InlineKeyboardButton( - text=f"❌ Удалить {coupon['code']}", callback_data=f"delete_coupon_{coupon['code']}" - ) - ) - if current_page > 1: - builder.row(InlineKeyboardButton(text="⬅️ Предыдущая", callback_data=f"coupons:{current_page - 1}")) - if current_page < total_pages: - builder.row(InlineKeyboardButton(text="➡️ Следующая", callback_data=f"coupons:{current_page + 1}")) - - 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}") await callback_query.message.answer("Произошла ошибка при получении списка купонов.") -@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(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 96686fa1..a5594caa 100644 --- a/handlers/admin/admin_panel.py +++ b/handlers/admin/admin_panel.py @@ -1,508 +1,63 @@ -import subprocess -from datetime import datetime -from io import BytesIO -from typing import Any - 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 database import delete_user_data, delete_blocked_user +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")) - 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() + text = ( + "🤖 Панель администратора" + f"\n📌 Версия бота: {version}" ) - -@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: - # Создаем кнопку для личного кабинета - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) - await bot.send_message(chat_id=tg_id, text=text_message, reply_markup=builder.as_markup()) - 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()) - -@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")) - 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(), + await state.clear() + await message.answer( + text=text, + reply_markup=build_panel_kb() ) -@router.callback_query(F.data == "export_to_csv") -async def export_banned_users_to_csv(callback_query: types.CallbackQuery, session: Any): - 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") - - 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(), - ) - - -@router.callback_query(F.data == "delete_banned_users") -async def delete_banned_users(callback_query: types.CallbackQuery, session: Any): - 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("📂 Нет заблокировавших пользователей для удаления.") - return - - for tg_id in blocked_ids: - await delete_user_data(session, tg_id) - - await delete_blocked_user(blocked_ids, session) - - 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(), - ) +@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(), + ) 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 5c9804c9..84675bfc 100644 --- a/handlers/admin/admin_servers.py +++ b/handlers/admin/admin_servers.py @@ -1,25 +1,22 @@ -import asyncio -from typing import Any - 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 create_server, check_unique_server_name, delete_server, get_keys_by_server, get_servers 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() @@ -27,196 +24,213 @@ 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, session: Any): - servers = await get_servers(session) +@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", - 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" + "Имя кластера должно быть уникальным!\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", - 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, session: Any): - server_name = message.text.strip() - - if server_name == "❌ Отменить": - await state.clear() - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔧 Управление кластерами", callback_data="servers_editor")) +@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( - "Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.", - reply_markup=builder.as_markup(), + text="❌ Имя сервера не может быть пустым. Попробуйте снова.", + reply_markup=build_admin_back_kb("servers") ) return - if not server_name: - await message.answer("❌ Имя сервера не может быть пустым. Попробуйте снова.") - return + server_name = message.text.strip() - server_unique = await check_unique_server_name(server_name, session) - if not server_unique: - await message.answer("❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.") + if not await check_unique_server_name(server_name): + await message.answer( + text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.", + reply_markup=build_admin_back_kb("servers") + ) return user_data = await state.get_data() 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", - 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://. Попробуйте снова.", - ) - 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 в информации о клиенте.", - 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://. Попробуйте снова.", - ) - 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", - 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, session: Any): +@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 должен быть числовым значением. Попробуйте снова.") + await message.answer( + text="❌ inbound_id должен быть числовым значением. Попробуйте снова.", + reply_markup=build_admin_back_kb("servers") + ) return user_data = await state.get_data() @@ -225,141 +239,78 @@ async def handle_inbound_id_input(message: types.Message, state: FSMContext, ses api_url = user_data.get("api_url") subscription_url = user_data.get("subscription_url") - await create_server( - cluster_name=cluster_name, - server_name=server_name, - api_url=api_url, - subscription_url=subscription_url, - inbound_id=inbound_id, - session=session, + conn = await asyncpg.connect(DATABASE_URL) + await conn.execute( + """ + INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id) + VALUES ($1, $2, $3, $4, $5) + """, + cluster_name, + server_name, + api_url, + subscription_url, + inbound_id, ) - - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="⬅️ Назад к кластерам", callback_data="servers_editor")) + await conn.close() 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, session: Any): - 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(session) 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, session: Any): - """Обработчик для синхронизации ключей на всех серверах выбранного кластера.""" - cluster_name = callback_query.data.split("|")[1] - - try: - keys_to_sync = await get_keys_by_server(None, cluster_name, session) - - 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(session) - 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(), - ) - - -@router.callback_query(F.data.startswith("server_availability|"), IsAdminFilter()) -async def handle_check_server_availability(callback_query: types.CallbackQuery, session: Any): - 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(session) 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 = f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n" + in_progress_message = await callback_query.message.answer( + text=text + ) + + text = ( + f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n" + ) for server in cluster_servers: xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD) @@ -368,103 +319,128 @@ async def handle_check_server_availability(callback_query: types.CallbackQuery, await xui.login() online_users = len(await xui.client.online()) - availability_message += f"🌍 {server['server_name']}: {online_users} активных пользователей.\n" + text += ( + f"🌍 {server['server_name']}: {online_users} активных пользователей.\n" + ) except Exception as e: - availability_message += f"❌ {server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n" + text += f"❌ {server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n" - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"manage_cluster|{cluster_name}")) - - await in_progress_message.edit_text(availability_message, reply_markup=builder.as_markup()) - - await callback_query.answer() + await in_progress_message.edit_text( + text=text, + reply_markup=build_admin_back_kb("servers") + ) -@router.callback_query(F.data.startswith("manage_server|"), IsAdminFilter()) -async def handle_manage_server(callback_query: types.CallbackQuery, state: FSMContext, session: Any): - 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() - servers = await get_servers(session) - - 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}", - 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] +@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_data.data - 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(), + await callback_query.message.edit_text( + text=f"🗑️ Вы уверены, что хотите удалить сервер {server_name}?", + reply_markup=build_delete_server_kb(server_name), ) -@router.callback_query(F.data.startswith("confirm_delete_server|"), IsAdminFilter()) -async def handle_confirm_delete_server(callback_query: types.CallbackQuery, state: FSMContext, session: Any): - server_name = callback_query.data.split("|")[1] +@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 await delete_server(server_name, session) - 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", - 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, session: Any): - 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(session) cluster_servers = servers.get(cluster_name, []) @@ -477,12 +453,12 @@ async def handle_backup_cluster(callback_query: types.CallbackQuery, session: An ) 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"🔔 Бэкапы отправлены в боты панелей.", - 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 index 2d470b56..a3aec08a 100644 --- a/handlers/admin/admin_user_editor.py +++ b/handlers/admin/admin_user_editor.py @@ -590,3 +590,4 @@ async def delete_user(callback_query: types.CallbackQuery, session: Any): 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 00e7098c..c1d6b66c 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 ClientConfig, 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 config import ADMIN_PASSWORD, ADMIN_USERNAME, LIMIT_IP, SUPERNODE, TOTAL_GB, PUBLIC_LINK +from database import get_servers, store_key +from handlers.utils import get_least_loaded_cluster from logger import logger @@ -103,6 +105,7 @@ async def create_client_on_server( 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() @@ -232,3 +235,53 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id except Exception as e: logger.error(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 753b0e2b..787bc25b 100644 --- a/handlers/keys/keys.py +++ b/handlers/keys/keys.py @@ -10,6 +10,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 ( @@ -51,10 +52,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, @@ -62,7 +62,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") @@ -73,7 +73,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 @@ -252,43 +252,15 @@ async def process_callback_view_key(callback_query: types.CallbackQuery, session async def process_callback_update_subscription(callback_query: types.CallbackQuery, session: Any): tg_id = callback_query.message.chat.id email = callback_query.data.split("|")[1] + try: - record = await get_key_details(email, session) - - if record: - expiry_time = record["expiry_time"] - client_id = record["client_id"] - public_link = f"{PUBLIC_LINK}{email}/{tg_id}" - - await delete_key(client_id, session) - - 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: - await handle_error(tg_id, callback_query, f"Ошибка при обновлении подписки: {e}") + logger.error(f"Ошибка при обновлении ключа {email} пользователем: {e}") + await handle_error( + tg_id, callback_query, f"Ошибка при обновлении подписки: {e}" + ) @router.callback_query(F.data.startswith("delete_key|")) @@ -363,7 +335,9 @@ 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): +async def process_callback_confirm_delete( + callback_query: types.CallbackQuery, session: Any +): email = callback_query.data.split("|")[1] try: record = await get_key_details(email, session) @@ -439,8 +413,7 @@ async def process_callback_renew_plan(callback_query: types.CallbackQuery, sessi required_amount = cost - balance logger.info( - f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}" - ) + f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}") await create_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 5c88f29b..29281507 100644 --- a/middlewares/delete.py +++ b/middlewares/delete.py @@ -4,23 +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 + )