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()