diff --git a/handlers/admin/management/keyboard.py b/handlers/admin/management/keyboard.py index 3bdcaa58..49718d1e 100644 --- a/handlers/admin/management/keyboard.py +++ b/handlers/admin/management/keyboard.py @@ -16,8 +16,8 @@ def build_management_kb(admin_role: str) -> InlineKeyboardMarkup: ) builder.button( - text="💾 Создать резервную копию", - callback_data=AdminPanelCallback(action="backups").pack(), + text="🗄 Управление БД", + callback_data=AdminPanelCallback(action="database").pack(), ) builder.button( text="📛 Управление банами", @@ -50,6 +50,50 @@ def build_management_kb(admin_role: str) -> InlineKeyboardMarkup: return builder.as_markup() +def build_database_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + builder.button( + text="💾 Создать резервную копию", + callback_data=AdminPanelCallback(action="backups").pack(), + ) + builder.button( + text="♻️ Восстановить БД", + callback_data=AdminPanelCallback(action="restore_db").pack(), + ) + builder.button( + text="📤 Получить данные БД", + callback_data=AdminPanelCallback(action="export_db").pack(), + ) + builder.row(build_admin_back_btn()) + builder.adjust(1) + return builder.as_markup() + + +def build_back_to_db_menu() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="⬅️ Назад", callback_data=AdminPanelCallback(action="database").pack() + ) + return builder.as_markup() + + +def build_export_db_sources_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + builder.button( + text="🌀 Remnawave", + callback_data=AdminPanelCallback(action="export_remnawave").pack() + ) + builder.button( + text="🔙 Назад", + callback_data=AdminPanelCallback(action="back_to_db_menu").pack() + ) + + builder.adjust(1) + return builder.as_markup() + + def build_admins_kb(admins: list[tuple[int, str]]) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() diff --git a/handlers/admin/management/management_handler.py b/handlers/admin/management/management_handler.py index f57bbc3f..76e7c8b6 100644 --- a/handlers/admin/management/management_handler.py +++ b/handlers/admin/management/management_handler.py @@ -5,15 +5,27 @@ from aiogram.types import CallbackQuery, Message from sqlalchemy import func, select, update, delete from sqlalchemy.ext.asyncio import AsyncSession import hashlib +import time +from dateutil import parser -from database.models import Key, Admin +import os, subprocess, sys +import json +from aiogram import Bot +from panels.remnawave import RemnawaveAPI +from tempfile import NamedTemporaryFile +import traceback +from datetime import datetime +from sqlalchemy.exc import SQLAlchemyError + +from database.models import Key, Admin, Server, User from filters.admin import IsAdminFilter from logger import logger from middlewares import maintenance from ..panel.keyboard import build_admin_back_kb -from .keyboard import AdminPanelCallback, build_management_kb, build_admins_kb, build_single_admin_menu, build_role_selection_kb, build_admin_back_kb_to_admins, build_token_result_kb +from .keyboard import AdminPanelCallback, build_management_kb, build_export_db_sources_kb, build_admins_kb, build_back_to_db_menu, build_single_admin_menu, build_role_selection_kb, build_database_kb, build_admin_back_kb_to_admins, build_token_result_kb from asyncio import sleep +from config import DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD router = Router() @@ -23,6 +35,10 @@ class AdminManagementStates(StatesGroup): waiting_for_new_domain = State() +class DatabaseState(StatesGroup): + waiting_for_backup_file = State() + + class AdminState(StatesGroup): waiting_for_tg_id = State() @@ -263,3 +279,285 @@ async def delete_admin(callback: CallbackQuery, callback_data: AdminPanelCallbac f"🗑 Админ {tg_id} удалён.", reply_markup=build_admin_back_kb_to_admins() ) + + +@router.callback_query(AdminPanelCallback.filter(F.action == "database")) +async def handle_database_menu(callback: CallbackQuery): + await callback.message.edit_text( + text="🗄 Управление базой данных", + reply_markup=build_database_kb(), + ) + + +@router.callback_query(AdminPanelCallback.filter(F.action == "restore_db")) +async def prompt_restore_db(callback: CallbackQuery, state: FSMContext): + await callback.message.edit_text( + "📂 Отправьте файл резервной копии (.sql), чтобы восстановить базу данных.\n" + "⚠️ Все текущие данные будут перезаписаны.", + reply_markup=build_back_to_db_menu(), + ) + await state.set_state(DatabaseState.waiting_for_backup_file) + + +@router.message(DatabaseState.waiting_for_backup_file) +async def restore_database(message: Message, state: FSMContext, bot: Bot): + + document = message.document + + if not document or not document.file_name.endswith(".sql"): + await message.answer("❌ Пожалуйста, отправьте файл с расширением .sql.") + return + + try: + with NamedTemporaryFile(delete=False, suffix=".sql") as tmp_file: + tmp_path = tmp_file.name + + await bot.download(document, destination=tmp_path) + logger.info(f"[Restore] Файл получен и сохранён: {tmp_path}") + + is_custom_dump = False + with open(tmp_path, "rb") as f: + signature = f.read(5) + if signature == b"PGDMP": + is_custom_dump = True + + logger.info(f"[Restore] Определён формат: {'custom' if is_custom_dump else 'plain'}") + + subprocess.run([ + "sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", + f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{DB_NAME}' AND pid <> pg_backend_pid();" + ], check=True) + + subprocess.run([ + "sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", + f"DROP DATABASE IF EXISTS {DB_NAME};" + ], check=True) + + subprocess.run([ + "sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", + f"CREATE DATABASE {DB_NAME} OWNER {DB_USER};" + ], check=True) + + logger.info("[Restore] База данных пересоздана") + + os.environ["PGPASSWORD"] = DB_PASSWORD + + if is_custom_dump: + result = subprocess.run([ + "pg_restore", + f"--dbname={DB_NAME}", + "-U", DB_USER, + "-h", PG_HOST, + "-p", PG_PORT, + "--no-owner", + "--exit-on-error", + tmp_path, + ], capture_output=True, text=True) + else: + result = subprocess.run([ + "psql", + "-U", DB_USER, + "-h", PG_HOST, + "-p", PG_PORT, + "-d", DB_NAME, + "-f", tmp_path, + ], capture_output=True, text=True) + + del os.environ["PGPASSWORD"] + + if result.returncode != 0: + logger.error(f"[Restore] Ошибка восстановления: {result.stderr}") + await message.answer( + f"❌ Ошибка при восстановлении базы данных:\n
{result.stderr}
", + ) + return + + await message.answer( + "✅ База данных восстановлена.", + reply_markup=build_back_to_db_menu(), + ) + logger.info("[Restore] Успешно восстановлено. Завершаем процесс для перезапуска.") + await state.clear() + sys.exit(0) + + except Exception as e: + logger.exception(f"[Restore] Непредвиденная ошибка: {e}") + await message.answer( + f"❌ Произошла ошибка:\n
{traceback.format_exc()}
", + ) + finally: + try: + os.remove(tmp_path) + except Exception: + pass + + +@router.callback_query(AdminPanelCallback.filter(F.action == "export_db")) +async def handle_export_db(callback: CallbackQuery): + await callback.message.edit_text( + "📤 Выберите панель, с которой требуется получить данные:\n\n" + "Подтянутся подписки с панели и будут сохранены в базу данных бота.", + reply_markup=build_export_db_sources_kb(), + ) + + +@router.callback_query(AdminPanelCallback.filter(F.action == "back_to_db_menu")) +async def back_to_database_menu(callback: CallbackQuery): + await callback.message.edit_text( + "📦 Управление базой данных:", + reply_markup=build_database_kb() + ) + + +@router.callback_query(AdminPanelCallback.filter(F.action == "export_remnawave")) +async def show_remnawave_clients(callback: CallbackQuery, session: AsyncSession): + await callback.answer() + + result = await session.execute( + select(Server).where(Server.panel_type == "remnawave", Server.enabled == True) + ) + servers = result.scalars().all() + + if not servers: + await callback.message.edit_text( + "❌ Нет доступных Remnawave-серверов.", + reply_markup=build_back_to_db_menu(), + ) + return + + server = servers[0] + api = RemnawaveAPI(base_url=server.api_url) + + if not await api.login(username=REMNAWAVE_LOGIN, password=REMNAWAVE_PASSWORD): + await callback.message.edit_text( + "❌ Не удалось авторизоваться на Remnawave панели.", + reply_markup=build_back_to_db_menu(), + ) + return + + users = await api.get_all_users() + if not users: + await callback.message.edit_text( + "📭 На панели нет клиентов.", + reply_markup=build_back_to_db_menu(), + ) + return + + logger.warning(f"[Remnawave Export] Пример ответа:\n{json.dumps(users[:3], indent=2, ensure_ascii=False)}") + + added_users = await import_remnawave_users(session, users) + + server_id = server.cluster_name or server.server_name + + added_keys = await import_remnawave_keys(session, users, server_id=server_id) + + preview = "" + for i, user in enumerate(users[:3], 1): + email = user.get("email") or user.get("username") or "-" + expire = user.get("expireAt", "")[:10] + preview += f"{i}. {email} — до {expire}\n" + + await callback.message.edit_text( + f"📄 Найдено клиентов: {len(users)}\n" + f"👤 Импортировано пользователей: {added_users}\n" + f"🔐 Импортировано ключей: {added_keys}\n\n" + f"Первые 3:\n{preview}", + reply_markup=build_back_to_db_menu(), + ) + + +async def import_remnawave_users(session: AsyncSession, users: list[dict]) -> int: + added = 0 + + for user in users: + tg_id = user.get("telegramId") + if not tg_id: + continue + + exists = await session.execute(select(User).where(User.tg_id == tg_id)) + if exists.scalar(): + continue + + try: + new_user = User( + tg_id=tg_id, + username=None, + first_name=None, + last_name=None, + language_code=None, + is_bot=False, + balance=0.0, + trial=1, + source_code=None, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + session.add(new_user) + added += 1 + + except SQLAlchemyError as e: + logger.error(f"[Remnawave Import] Ошибка при добавлении пользователя {tg_id}: {e}") + continue + + await session.commit() + return added + + +async def import_remnawave_keys(session: AsyncSession, users: list[dict], server_id: str) -> int: + added = 0 + + for user in users: + tg_id = user.get("telegramId") + client_id = user.get("uuid") + email = user.get("email") or user.get("username") + remnawave_link = user.get("subscriptionUrl") + expire_at = user.get("expireAt") + created_at = user.get("createdAt") + + if not tg_id or not client_id: + logger.warning(f"[SKIP] Пропущен клиент: tg_id={tg_id}, client_id={client_id}") + continue + + exists_stmt = await session.execute( + select(Key).where(Key.client_id == client_id) + ) + if exists_stmt.scalar(): + logger.info(f"[SKIP] Ключ уже существует: {client_id}") + continue + + try: + created_ts = ( + int(parser.isoparse(created_at).timestamp() * 1000) + if created_at else int(time.time() * 1000) + ) + expire_ts = ( + int(parser.isoparse(expire_at).timestamp() * 1000) + if expire_at else int(time.time() * 1000) + ) + + new_key = Key( + tg_id=tg_id, + client_id=client_id, + email=email, + created_at=created_ts, + expiry_time=expire_ts, + key="", + server_id=server_id, + remnawave_link=remnawave_link, + tariff_id=None, + is_frozen=False, + alias=None, + notified=False, + notified_24h=False, + ) + session.add(new_key) + added += 1 + + logger.info(f"[ADD] Ключ добавлен: {client_id}, до {expire_at}, email={email}, server_id={server_id}") + + except Exception as e: + logger.error(f"[ERROR] Ошибка при добавлении ключа {client_id}: {e}") + + await session.commit() + logger.info(f"[IMPORT] Всего добавлено ключей: {added}") + return added diff --git a/handlers/admin/panel/keyboard.py b/handlers/admin/panel/keyboard.py index f77f0da6..1c23cae2 100644 --- a/handlers/admin/panel/keyboard.py +++ b/handlers/admin/panel/keyboard.py @@ -24,17 +24,13 @@ def build_panel_kb() -> InlineKeyboardMarkup: callback_data=AdminPanelCallback(action="search_user").pack(), ) builder.button( - text="🔑 Поиск по названию ключа", + text="🔑 Поиск по ключу", callback_data=AdminPanelCallback(action="search_key").pack(), ) - builder.row( - InlineKeyboardButton( - text="🖥️ Серверы", callback_data=AdminPanelCallback(action="clusters").pack() - ), - InlineKeyboardButton( - text="🤖 Управление", - callback_data=AdminPanelCallback(action="management").pack(), - ), + + builder.button( + text="🖥️ Управление серверами", + callback_data=AdminPanelCallback(action="clusters").pack(), ) builder.row( InlineKeyboardButton( @@ -47,12 +43,15 @@ def build_panel_kb() -> InlineKeyboardMarkup: builder.row( InlineKeyboardButton( text="💸 Тарифы", callback_data=AdminPanelCallback(action="tariffs").pack() - ) - ) - builder.row( + ), InlineKeyboardButton( text="🎁 Подарки", callback_data=AdminPanelCallback(action="gifts").pack() - ) + ), + ) + + builder.button( + text="🤖 Управление ботом", + callback_data=AdminPanelCallback(action="management").pack(), ) builder.row( InlineKeyboardButton( @@ -60,11 +59,18 @@ def build_panel_kb() -> InlineKeyboardMarkup: callback_data=AdminPanelCallback(action="stats").pack(), ), InlineKeyboardButton( - text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack() + text="📈 Аналитика", + callback_data=AdminPanelCallback(action="ads").pack(), ), ) - builder.button(text=MAIN_MENU, callback_data="profile") - builder.adjust(1, 1, 2, 2, 1, 1, 2, 1) + + builder.button( + text=MAIN_MENU, + callback_data="profile", + ) + + builder.adjust(1, 1, 1, 2, 2, 1, 2, 1) + return builder.as_markup() diff --git a/handlers/buttons.py b/handlers/buttons.py index 08126c24..6ae7a99e 100644 --- a/handlers/buttons.py +++ b/handlers/buttons.py @@ -76,7 +76,7 @@ PC_MACOS = "🍏 macOS" TV = "📺 Телевизор" ROUTER = "📶 Роутер" MANUAL_INSTRUCTIONS = "📖 Ручная установка" -RENEW_KEY = "🛒 Купить VPN | 🔄 Продлить VPN" +RENEW_KEY = "Купить VPN 🔄 Продлить VPN" RENEW_KEY_NOTIFICATION = "🔄 Продлить подписку" TV_CONTINUE = "▶ Продолжить" TV_INSTRUCTIONS = "📖 Полная инструкция" diff --git a/panels/remnawave.cpython-312-x86_64-linux-gnu.so b/panels/remnawave.cpython-312-x86_64-linux-gnu.so index fae94ad7..9363ca86 100644 Binary files a/panels/remnawave.cpython-312-x86_64-linux-gnu.so and b/panels/remnawave.cpython-312-x86_64-linux-gnu.so differ