Sync Remnawave panel / import users & keys / restore DB / rename buttons / UI tweaks

This commit is contained in:
Vladless
2025-06-19 05:09:29 +03:00
parent 0dd6dcda65
commit 30d8ef4bee
5 changed files with 369 additions and 21 deletions
+46 -2
View File
@@ -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()
+300 -2
View File
@@ -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"🗑 Админ <code>{tg_id}</code> удалён.",
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="🗄 <b>Управление базой данных</b>",
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<pre>{result.stderr}</pre>",
)
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<pre>{traceback.format_exc()}</pre>",
)
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"
"<i>Подтянутся подписки с панели и будут сохранены в базу данных бота.</i>",
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"📄 Найдено клиентов: <b>{len(users)}</b>\n"
f"👤 Импортировано пользователей: <b>{added_users}</b>\n"
f"🔐 Импортировано ключей: <b>{added_keys}</b>\n\n"
f"<b>Первые 3:</b>\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
+22 -16
View File
@@ -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()
+1 -1
View File
@@ -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 = "📖 Полная инструкция"
Binary file not shown.