From 3fd7865197ef83c10f620e875a2635906c9855fd Mon Sep 17 00:00:00 2001 From: hteppl Date: Tue, 25 Mar 2025 17:12:47 +0300 Subject: [PATCH] Trying to split cluster and server manage logic --- handlers/admin/__init__.py | 2 + handlers/admin/clusters/__init__.py | 3 + handlers/admin/clusters/clusters_handler.py | 421 ++++++++++++++++++ handlers/admin/clusters/keyboard.py | 58 +++ handlers/admin/servers/keyboard.py | 61 +-- handlers/admin/servers/servers_handler.py | 462 +------------------- 6 files changed, 503 insertions(+), 504 deletions(-) create mode 100644 handlers/admin/clusters/__init__.py create mode 100644 handlers/admin/clusters/clusters_handler.py create mode 100644 handlers/admin/clusters/keyboard.py diff --git a/handlers/admin/__init__.py b/handlers/admin/__init__.py index de5256be..f77eff4d 100644 --- a/handlers/admin/__init__.py +++ b/handlers/admin/__init__.py @@ -4,6 +4,7 @@ from aiogram import Router from .backups import router as backups_router from .bans import router as bans_router +from .clusters import router as clusters_router from .coupons import router as coupons_router from .panel import router as panel_router from .restart import router as restart_router @@ -17,6 +18,7 @@ router = Router(name="admins_main_router") router.include_routers( panel_router, servers_router, + clusters_router, users_router, stats_router, backups_router, diff --git a/handlers/admin/clusters/__init__.py b/handlers/admin/clusters/__init__.py new file mode 100644 index 00000000..58b5c5e4 --- /dev/null +++ b/handlers/admin/clusters/__init__.py @@ -0,0 +1,3 @@ +__all__ = ("router",) + +from clusters_handler import router diff --git a/handlers/admin/clusters/clusters_handler.py b/handlers/admin/clusters/clusters_handler.py new file mode 100644 index 00000000..31496a69 --- /dev/null +++ b/handlers/admin/clusters/clusters_handler.py @@ -0,0 +1,421 @@ +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 CallbackQuery, Message +from py3xui import AsyncApi + +from backup import create_backup_and_send_to_admins +from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL +from database import check_unique_server_name, get_servers +from filters.admin import IsAdminFilter +from handlers.keys.key_utils import create_key_on_cluster +from keyboard import ( + build_clusters_editor_kb, + build_manage_cluster_kb, AdminClusterCallback, +) +from logger import logger +from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb + +router = Router() + + +class AdminClusterStates(StatesGroup): + waiting_for_cluster_name = State() + waiting_for_api_url = State() + waiting_for_inbound_id = State() + waiting_for_server_name = State() + waiting_for_subscription_url = State() + waiting_for_new_domain = State() + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "clusters"), + IsAdminFilter(), +) +async def handle_servers(callback_query: CallbackQuery): + servers = await get_servers() + + text = ( + "🔧 Управление кластерами\n\n" + "📌 Здесь вы можете добавить новый кластер.\n\n" + "🌐 Кластеры — это пространство серверов, в пределах которого создается подписка.\n" + "💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n" + "⚠️ Важно: Кластеры удаляются автоматически, если удалить все серверы внутри них.\n\n" + ) + + await callback_query.message.edit_text( + text=text, + reply_markup=build_clusters_editor_kb(servers), + ) + + +@router.callback_query( + AdminClusterCallback.filter(F.action == "add"), + IsAdminFilter(), +) +async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext): + text = ( + "🔧 Введите имя нового кластера:\n\n" + "Имя должно быть уникальным!\n" + "Имя не должно превышать 12 символов!\n" + "Пример: cluster1 или us_east_1" + ) + + await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb("clusters")) + + await state.set_state(AdminClusterStates.waiting_for_cluster_name) + + +@router.message(AdminClusterStates.waiting_for_cluster_name, IsAdminFilter()) +async def handle_cluster_name_input(message: Message, state: FSMContext): + if not message.text: + await message.answer( + text="❌ Имя кластера не может быть пустым! Попробуйте снова.", reply_markup=build_admin_back_kb("clusters") + ) + return + + if len(message.text) > 12: + await message.answer( + text="❌ Имя кластера должно превышать 12 символов! Попробуйте снова.", + reply_markup=build_admin_back_kb("clusters"), + ) + return + + cluster_name = message.text.strip() + await state.update_data(cluster_name=cluster_name) + + text = ( + f"Введите имя сервера для кластера {cluster_name}:\n\n" + "Рекомендуется указать локацию и номер сервера в имени.\n\n" + "Пример: de1, fra1, fi2" + ) + + await message.answer( + text=text, + reply_markup=build_admin_back_kb("clusters"), + ) + + await state.set_state(AdminClusterStates.waiting_for_server_name) + + +@router.message(AdminClusterStates.waiting_for_server_name, IsAdminFilter()) +async def handle_server_name_input(message: Message, state: FSMContext, session: Any): + if not message.text: + await message.answer( + text="❌ Имя сервера не может быть пустым. Попробуйте снова.", reply_markup=build_admin_back_kb("clusters") + ) + return + + server_name = message.text.strip() + + if len(server_name) > 12: + await message.answer( + text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.", + reply_markup=build_admin_back_kb("clusters"), + ) + return + + user_data = await state.get_data() + cluster_name = user_data.get("cluster_name") + + if not await check_unique_server_name(server_name, session, cluster_name): + await message.answer( + text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.", + reply_markup=build_admin_back_kb("clusters"), + ) + return + + await state.update_data(server_name=server_name) + + text = ( + f"Введите API URL для сервера {server_name} в кластере {cluster_name}:\n\n" + "Ссылку можно найти в поисковой строке браузера, при входе в 3X-UI.\n\n" + "ℹ️ Формат API URL:\n" + "https://your_domain:port/panel_path/" + ) + + await message.answer( + text=text, + reply_markup=build_admin_back_kb("clusters"), + ) + + await state.set_state(AdminClusterStates.waiting_for_api_url) + + +@router.message(AdminClusterStates.waiting_for_api_url, IsAdminFilter()) +async def handle_api_url_input(message: Message, state: FSMContext): + if not message.text or not message.text.strip().startswith("https://"): + await message.answer( + text="❌ API URL должен начинаться с https://. Попробуйте снова.", + reply_markup=build_admin_back_kb("clusters"), + ) + return + + 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) + + text = ( + f"Введите subscription_url для сервера {server_name} в кластере {cluster_name}:\n\n" + "Ссылку можно найти в панели 3X-UI, в информации о клиенте.\n\n" + "ℹ️ Формат Subscription URL:\n" + "https://your_domain:port_sub/sub_path/" + ) + + await message.answer( + text=text, + reply_markup=build_admin_back_kb("clusters"), + ) + + await state.set_state(AdminClusterStates.waiting_for_subscription_url) + + +@router.message(AdminClusterStates.waiting_for_subscription_url, IsAdminFilter()) +async def handle_subscription_url_input(message: Message, state: FSMContext): + if not message.text or not message.text.strip().startswith("https://"): + await message.answer( + text="❌ subscription_url должен начинаться с https://. Попробуйте снова.", + reply_markup=build_admin_back_kb("clusters"), + ) + return + + 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) + + text = ( + f"Введите inbound_id для сервера {server_name} в кластере {cluster_name}:\n\n" + "Это номер подключения vless в вашей панели 3x-ui. Обычно это 1 при чистой настройке по гайду.\n\n" + ) + + await message.answer( + text=text, + reply_markup=build_admin_back_kb("clusters"), + ) + await state.set_state(AdminClusterStates.waiting_for_inbound_id) + + +@router.message(AdminClusterStates.waiting_for_inbound_id, IsAdminFilter()) +async def handle_inbound_id_input(message: Message, state: FSMContext): + inbound_id = message.text.strip() + + if not inbound_id.isdigit(): + await message.answer( + text="❌ inbound_id должен быть числовым значением. Попробуйте снова.", + reply_markup=build_admin_back_kb("clusters"), + ) + return + + user_data = await state.get_data() + cluster_name = user_data.get("cluster_name") + server_name = user_data.get("server_name") + api_url = user_data.get("api_url") + subscription_url = user_data.get("subscription_url") + + 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, + ) + await conn.close() + + await message.answer( + text=f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!", + reply_markup=build_admin_back_kb("clusters"), + ) + + await state.clear() + + +@router.callback_query(AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter()) +async def handle_clusters_manage( + callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any +): + cluster_name = callback_data.data + + servers = await get_servers(session) + cluster_servers = servers.get(cluster_name, []) + + await callback_query.message.edit_text( + text=f"🔧 Управление серверами для кластера {cluster_name}", + reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name), + ) + + +@router.callback_query(AdminClusterCallback.filter(F.action == "availability"), IsAdminFilter()) +async def handle_cluster_availability( + callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any +): + cluster_name = callback_data.data + + servers = await get_servers(session) + cluster_servers = servers.get(cluster_name, []) + + if not cluster_servers: + await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.") + return + + text = ( + f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n" + "Это может занять до 1 минуты, пожалуйста, подождите..." + ) + + await callback_query.message.edit_text(text=text) + + total_online_users = 0 + result_text = f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n" + + for server in cluster_servers: + xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger) + + try: + await xui.login() + online_users = len(await xui.client.online()) + total_online_users += online_users + result_text += f"🌍 {server['server_name']}: {online_users} активных пользователей.\n" + except Exception as e: + result_text += f"❌ {server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n" + + result_text += f"\n👥 Общее количество активных пользователей в кластере: {total_online_users}." + + await callback_query.message.edit_text(text=result_text, reply_markup=build_admin_back_kb("clusters")) + + +@router.callback_query(AdminClusterCallback.filter(F.action == "backup"), IsAdminFilter()) +async def handle_clusters_backup( + callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any +): + cluster_name = callback_data.data + + servers = await get_servers(session) + cluster_servers = servers.get(cluster_name, []) + + for server in cluster_servers: + xui = AsyncApi( + server["api_url"], + username=ADMIN_USERNAME, + password=ADMIN_PASSWORD, + logger=logger, + ) + await create_backup_and_send_to_admins(xui) + + text = ( + f"Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!\n\n" + f"🔔 Бэкапы отправлены в боты панелей." + ) + + await callback_query.message.edit_text( + text=text, + reply_markup=build_admin_back_kb("clusters"), + ) + + +@router.callback_query(AdminClusterCallback.filter(F.action == "sync"), IsAdminFilter()) +async def handle_clusters_sync( + callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any +): + cluster_name = callback_data.data + + try: + query_keys = """ + SELECT tg_id, client_id, email, expiry_time + FROM keys + WHERE server_id = $1 + """ + keys_to_sync = await session.fetch(query_keys, cluster_name) + + if not keys_to_sync: + await callback_query.message.answer( + text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.", + reply_markup=build_admin_back_kb("clusters"), + ) + return + + for key in keys_to_sync: + 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( + text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}", + reply_markup=build_admin_back_kb("clusters"), + ) + except Exception as e: + logger.error(f"Ошибка синхронизации ключей в кластере {cluster_name}: {e}") + await callback_query.message.answer( + text=f"❌ Произошла ошибка при синхронизации: {e}", reply_markup=build_admin_back_kb("clusters") + ) + + +@router.callback_query(AdminClusterCallback.filter(F.action == "change_domain"), IsAdminFilter()) +async def request_new_domain(callback_query: AdminClusterCallback, state: FSMContext): + """Запрашивает у администратора новый домен.""" + await state.set_state(AdminClusterStates.waiting_for_new_domain) + await callback_query.message.edit_text( + text="🌐 Введите новый домен (без https://):\nПример: pocomachodomen.ru", + ) + + +@router.message(AdminClusterStates.waiting_for_new_domain) +async def process_new_domain(message: Message, state: FSMContext, session: asyncpg.Connection): + """Обновляет домен в таблице keys.""" + new_domain = message.text.strip() + logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'") + + if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum(): + logger.warning("[DomainChange] Некорректный домен") + await message.answer( + "🚫 Некорректный домен! Введите домен без http:// и без пробелов.", + reply_markup=build_admin_back_kb("admin"), + ) + return + + new_domain_url = f"https://{new_domain}" + logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'") + + query = """ + UPDATE keys + SET key = regexp_replace(key, '^https://[^/]+', $1::TEXT) + WHERE key NOT LIKE $1 || '%' + """ + try: + await session.execute(query, new_domain_url) + logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.") + except Exception as e: + logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}") + await message.answer(f"❌ Ошибка при обновлении домена: {e}", reply_markup=build_admin_back_kb("admin")) + return + + try: + sample = await session.fetchrow("SELECT key FROM keys LIMIT 1") + logger.info(f"[DomainChange] Пример обновленной записи: {sample}") + except Exception as e: + logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}") + + await message.answer(f"✅ Домен успешно изменен на {new_domain}!", reply_markup=build_admin_back_kb("admin")) + await state.clear() diff --git a/handlers/admin/clusters/keyboard.py b/handlers/admin/clusters/keyboard.py new file mode 100644 index 00000000..f453ef28 --- /dev/null +++ b/handlers/admin/clusters/keyboard.py @@ -0,0 +1,58 @@ +from aiogram.filters.callback_data import CallbackData +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from ..panel.keyboard import build_admin_back_btn +from ..servers.keyboard import AdminServerCallback + + +class AdminClusterCallback(CallbackData, prefix="admin_cluster"): + 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=AdminClusterCallback(action="manage", data=cluster_name).pack(), + ) + + builder.button(text="➕ Добавить кластер", callback_data=AdminClusterCallback(action="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=AdminServerCallback(action="manage", data=server["server_name"]).pack(), + ) + + builder.button( + text="➕ Добавить сервер", + callback_data=AdminServerCallback(action="add", data=cluster_name).pack(), + ) + builder.row( + InlineKeyboardButton( + text="🌐 Доступность", + callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack(), + ), + InlineKeyboardButton( + text="🔄 Синхронизация", + callback_data=AdminClusterCallback(action="sync", data=cluster_name).pack(), + ), + ) + builder.button( + text="💾 Создать бэкап кластера", + callback_data=AdminClusterCallback(action="backup", data=cluster_name).pack(), + ) + builder.row(build_admin_back_btn("servers")) + builder.adjust(1, 1, 1, 1, 1, 2, 1) + return builder.as_markup() diff --git a/handlers/admin/servers/keyboard.py b/handlers/admin/servers/keyboard.py index 762c697d..ebc5e390 100644 --- a/handlers/admin/servers/keyboard.py +++ b/handlers/admin/servers/keyboard.py @@ -1,69 +1,22 @@ from aiogram.filters.callback_data import CallbackData -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.types import InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder -from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn +from ..clusters.keyboard import AdminClusterCallback -class AdminServerEditorCallback(CallbackData, prefix="admin_servers"): +class AdminServerCallback(CallbackData, prefix="admin_server"): 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.row( - InlineKeyboardButton( - text="🌐 Доступность", - callback_data=AdminServerEditorCallback(action="servers_availability", data=cluster_name).pack(), - ), - InlineKeyboardButton( - text="🔄 Синхронизация", - callback_data=AdminServerEditorCallback(action="clusters_sync", 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, 1, 1, 1, 1, 2, 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() + text="🗑️ Удалить", callback_data=AdminServerCallback(action="delete", data=server_name).pack() ) builder.button( - text="🔙 Назад", callback_data=AdminServerEditorCallback(action="clusters_manage", data=cluster_name).pack() + text="🔙 Назад", callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack() ) builder.adjust(1) return builder.as_markup() @@ -72,10 +25,10 @@ def build_manage_server_kb(server_name: str, cluster_name: str) -> InlineKeyboar 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() + text="✅ Да", callback_data=AdminServerCallback(action="delete_confirm", data=server_name).pack() ) builder.button( - text="🔙 Назад", callback_data=AdminServerEditorCallback(action="servers_manage", data=server_name).pack() + text="🔙 Назад", callback_data=AdminServerCallback(action="manage", data=server_name).pack() ) builder.adjust(1) return builder.as_markup() diff --git a/handlers/admin/servers/servers_handler.py b/handlers/admin/servers/servers_handler.py index 2b58393f..c5a07a41 100644 --- a/handlers/admin/servers/servers_handler.py +++ b/handlers/admin/servers/servers_handler.py @@ -1,311 +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 CallbackQuery, Message -from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL -from py3xui import AsyncApi +from aiogram.types import CallbackQuery -from backup import create_backup_and_send_to_admins -from database import check_unique_server_name, delete_server, get_servers +from database import delete_server, get_servers from filters.admin import IsAdminFilter -from handlers.keys.key_utils import create_key_on_cluster -from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb from keyboard import ( - AdminServerEditorCallback, - build_clusters_editor_kb, + AdminServerCallback, build_delete_server_kb, - build_manage_cluster_kb, build_manage_server_kb, ) -from logger import logger - +from ..panel.keyboard import build_admin_back_kb router = Router() -class AdminServersEditor(StatesGroup): - waiting_for_cluster_name = State() - waiting_for_api_url = State() - waiting_for_inbound_id = State() - waiting_for_server_name = State() - waiting_for_subscription_url = State() - waiting_for_new_domain = State() - - -@router.callback_query( - AdminPanelCallback.filter(F.action == "servers"), - IsAdminFilter(), -) -async def handle_servers(callback_query: CallbackQuery): - servers = await get_servers() - - text = ( - "🔧 Управление кластерами\n\n" - "📌 Здесь вы можете добавить новый кластер.\n\n" - "🌐 Кластеры — это пространство серверов, в пределах которого создается подписка.\n" - "💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n" - "⚠️ Важно: Кластеры удаляются автоматически, если удалить все серверы внутри них.\n\n" - ) - - await callback_query.message.edit_text( - text=text, - reply_markup=build_clusters_editor_kb(servers), - ) - - -@router.callback_query( - AdminPanelCallback.filter(F.action == "clusters_add"), - IsAdminFilter(), -) -async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext): - text = ( - "🔧 Введите имя нового кластера:\n\n" - "Имя должно быть уникальным!\n" - "Имя не должно превышать 12 символов!\n" - "Пример: cluster1 или us_east_1" - ) - - 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(AdminServersEditor.waiting_for_cluster_name, IsAdminFilter()) -async def handle_cluster_name_input(message: Message, state: FSMContext): - if not message.text: - await message.answer( - text="❌ Имя кластера не может быть пустым! Попробуйте снова.", reply_markup=build_admin_back_kb("servers") - ) - return - - if len(message.text) > 12: - await message.answer( - text="❌ Имя кластера должно превышать 12 символов! Попробуйте снова.", - reply_markup=build_admin_back_kb("servers"), - ) - return - - cluster_name = message.text.strip() - await state.update_data(cluster_name=cluster_name) - - text = ( - f"Введите имя сервера для кластера {cluster_name}:\n\n" - "Рекомендуется указать локацию и номер сервера в имени.\n\n" - "Пример: de1, fra1, fi2" - ) - - await message.answer( - text=text, - reply_markup=build_admin_back_kb("servers"), - ) - - await state.set_state(AdminServersEditor.waiting_for_server_name) - - -@router.message(AdminServersEditor.waiting_for_server_name, IsAdminFilter()) -async def handle_server_name_input(message: Message, state: FSMContext, session: Any): - if not message.text: - await message.answer( - text="❌ Имя сервера не может быть пустым. Попробуйте снова.", reply_markup=build_admin_back_kb("servers") - ) - return - - server_name = message.text.strip() - - if len(server_name) > 12: - await message.answer( - text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.", - reply_markup=build_admin_back_kb("servers"), - ) - return - - user_data = await state.get_data() - cluster_name = user_data.get("cluster_name") - - if not await check_unique_server_name(server_name, session, cluster_name): - await message.answer( - text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.", - reply_markup=build_admin_back_kb("servers"), - ) - return - - await state.update_data(server_name=server_name) - - text = ( - f"Введите API URL для сервера {server_name} в кластере {cluster_name}:\n\n" - "Ссылку можно найти в поисковой строке браузера, при входе в 3X-UI.\n\n" - "ℹ️ Формат API URL:\n" - "https://your_domain:port/panel_path/" - ) - - await message.answer( - text=text, - reply_markup=build_admin_back_kb("servers"), - ) - - await state.set_state(AdminServersEditor.waiting_for_api_url) - - -@router.message(AdminServersEditor.waiting_for_api_url, IsAdminFilter()) -async def handle_api_url_input(message: Message, state: FSMContext): - if not message.text or not message.text.strip().startswith("https://"): - await message.answer( - text="❌ API URL должен начинаться с https://. Попробуйте снова.", - reply_markup=build_admin_back_kb("servers"), - ) - return - - 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) - - text = ( - f"Введите subscription_url для сервера {server_name} в кластере {cluster_name}:\n\n" - "Ссылку можно найти в панели 3X-UI, в информации о клиенте.\n\n" - "ℹ️ Формат Subscription URL:\n" - "https://your_domain:port_sub/sub_path/" - ) - - await message.answer( - text=text, - reply_markup=build_admin_back_kb("servers"), - ) - - await state.set_state(AdminServersEditor.waiting_for_subscription_url) - - -@router.message(AdminServersEditor.waiting_for_subscription_url, IsAdminFilter()) -async def handle_subscription_url_input(message: Message, state: FSMContext): - if not message.text or not message.text.strip().startswith("https://"): - await message.answer( - text="❌ subscription_url должен начинаться с https://. Попробуйте снова.", - reply_markup=build_admin_back_kb("servers"), - ) - return - - 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) - - text = ( - f"Введите inbound_id для сервера {server_name} в кластере {cluster_name}:\n\n" - "Это номер подключения vless в вашей панели 3x-ui. Обычно это 1 при чистой настройке по гайду.\n\n" - ) - - await message.answer( - text=text, - reply_markup=build_admin_back_kb("servers"), - ) - await state.set_state(AdminServersEditor.waiting_for_inbound_id) - - -@router.message(AdminServersEditor.waiting_for_inbound_id, IsAdminFilter()) -async def handle_inbound_id_input(message: Message, state: FSMContext): - inbound_id = message.text.strip() - - if not inbound_id.isdigit(): - await message.answer( - text="❌ inbound_id должен быть числовым значением. Попробуйте снова.", - reply_markup=build_admin_back_kb("servers"), - ) - return - - user_data = await state.get_data() - cluster_name = user_data.get("cluster_name") - server_name = user_data.get("server_name") - api_url = user_data.get("api_url") - subscription_url = user_data.get("subscription_url") - - 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, - ) - await conn.close() - - await message.answer( - text=f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!", - reply_markup=build_admin_back_kb("servers"), - ) - - await state.clear() - - -@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_manage"), IsAdminFilter()) -async def handle_clusters_manage( - callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any -): - cluster_name = callback_data.data - - servers = await get_servers(session) - cluster_servers = servers.get(cluster_name, []) - - await callback_query.message.edit_text( - text=f"🔧 Управление серверами для кластера {cluster_name}", - reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name), - ) - - -@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_availability"), IsAdminFilter()) -async def handle_servers_availability( - callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any -): - cluster_name = callback_data.data - - servers = await get_servers(session) - cluster_servers = servers.get(cluster_name, []) - - if not cluster_servers: - await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.") - return - - text = ( - f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n" - "Это может занять до 1 минуты, пожалуйста, подождите..." - ) - - await callback_query.message.edit_text(text=text) - - total_online_users = 0 - result_text = f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n" - - for server in cluster_servers: - xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=logger) - - try: - await xui.login() - online_users = len(await xui.client.online()) - total_online_users += online_users - result_text += f"🌍 {server['server_name']}: {online_users} активных пользователей.\n" - except Exception as e: - result_text += f"❌ {server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n" - - result_text += f"\n👥 Общее количество активных пользователей в кластере: {total_online_users}." - - await callback_query.message.edit_text(text=result_text, reply_markup=build_admin_back_kb("servers")) - - -@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_manage"), IsAdminFilter()) -async def handle_servers_manage(callback_query: CallbackQuery, callback_data: AdminServerEditorCallback): +@router.callback_query(AdminServerCallback.filter(F.action == "manage"), IsAdminFilter()) +async def handle_server_manage(callback_query: CallbackQuery, callback_data: AdminServerCallback): server_name = callback_data.data servers = await get_servers() @@ -333,8 +44,8 @@ async def handle_servers_manage(callback_query: CallbackQuery, callback_data: Ad await callback_query.message.edit_text(text="❌ Сервер не найден.") -@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_delete"), IsAdminFilter()) -async def handle_servers_delete(callback_query: CallbackQuery, callback_data: AdminServerEditorCallback): +@router.callback_query(AdminServerCallback.filter(F.action == "delete"), IsAdminFilter()) +async def handle_server_delete(callback_query: CallbackQuery, callback_data: AdminServerCallback): server_name = callback_data.data await callback_query.message.edit_text( @@ -343,163 +54,14 @@ async def handle_servers_delete(callback_query: CallbackQuery, callback_data: Ad ) -@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_delete_confirm"), IsAdminFilter()) -async def handle_servers_delete_confirm( - callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any +@router.callback_query(AdminServerCallback.filter(F.action == "delete_confirm"), IsAdminFilter()) +async def handle_server_delete_confirm( + callback_query: types.CallbackQuery, callback_data: AdminServerCallback, session: Any ): server_name = callback_data.data await delete_server(server_name, session) await callback_query.message.edit_text( - text=f"🗑️ Сервер {server_name} успешно удален.", reply_markup=build_admin_back_kb("servers") + text=f"🗑️ Сервер {server_name} успешно удален.", reply_markup=build_admin_back_kb("clusters") ) - - -@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) - - text = ( - f"✏️ Введите название нового сервера для кластера {cluster_name}:\n\n" - "🌍 Рекомендуется использовать локацию и порядковый номер сервера в названии.\n" - "Имя не должно превышать 12 символов!\n\n" - "Пример:\n" - "— de1 - для первого сервера в Германии\n" - "— swe2 - для второго сервера в Швеции\n" - "(если в каком-то кластере у вас уже есть сервер в Швеции)" - ) - - 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(AdminServerEditorCallback.filter(F.action == "clusters_backup"), IsAdminFilter()) -async def handle_clusters_backup( - callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any -): - cluster_name = callback_data.data - - servers = await get_servers(session) - cluster_servers = servers.get(cluster_name, []) - - for server in cluster_servers: - xui = AsyncApi( - server["api_url"], - username=ADMIN_USERNAME, - password=ADMIN_PASSWORD, - logger=logger, - ) - await create_backup_and_send_to_admins(xui) - - text = ( - f"Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!\n\n" - f"🔔 Бэкапы отправлены в боты панелей." - ) - - await callback_query.message.edit_text( - text=text, - reply_markup=build_admin_back_kb("servers"), - ) - - -@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_sync"), IsAdminFilter()) -async def handle_clusters_sync( - callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any -): - cluster_name = callback_data.data - - try: - query_keys = """ - SELECT tg_id, client_id, email, expiry_time - FROM keys - WHERE server_id = $1 - """ - keys_to_sync = await session.fetch(query_keys, cluster_name) - - if not keys_to_sync: - await callback_query.message.answer( - text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.", - reply_markup=build_admin_back_kb("servers"), - ) - return - - for key in keys_to_sync: - 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( - text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}", - reply_markup=build_admin_back_kb("servers"), - ) - except Exception as e: - logger.error(f"Ошибка синхронизации ключей в кластере {cluster_name}: {e}") - await callback_query.message.answer( - text=f"❌ Произошла ошибка при синхронизации: {e}", reply_markup=build_admin_back_kb("servers") - ) - - -@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter()) -async def request_new_domain(callback_query: CallbackQuery, state: FSMContext): - """Запрашивает у администратора новый домен.""" - await state.set_state(AdminServersEditor.waiting_for_new_domain) - await callback_query.message.edit_text( - text="🌐 Введите новый домен (без https://):\nПример: pocomachodomen.ru", - ) - - -@router.message(AdminServersEditor.waiting_for_new_domain) -async def process_new_domain(message: Message, state: FSMContext, session: asyncpg.Connection): - """Обновляет домен в таблице keys.""" - new_domain = message.text.strip() - logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'") - - if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum(): - logger.warning("[DomainChange] Некорректный домен") - await message.answer( - "🚫 Некорректный домен! Введите домен без http:// и без пробелов.", - reply_markup=build_admin_back_kb("admin"), - ) - return - - new_domain_url = f"https://{new_domain}" - logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'") - - query = """ - UPDATE keys - SET key = regexp_replace(key, '^https://[^/]+', $1::TEXT) - WHERE key NOT LIKE $1 || '%' - """ - try: - await session.execute(query, new_domain_url) - logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.") - except Exception as e: - logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}") - await message.answer(f"❌ Ошибка при обновлении домена: {e}", reply_markup=build_admin_back_kb("admin")) - return - - try: - sample = await session.fetchrow("SELECT key FROM keys LIMIT 1") - logger.info(f"[DomainChange] Пример обновленной записи: {sample}") - except Exception as e: - logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}") - - await message.answer(f"✅ Домен успешно изменен на {new_domain}!", reply_markup=build_admin_back_kb("admin")) - await state.clear()