diff --git a/backup.py b/backup.py
index e44b45f1..10135d71 100644
--- a/backup.py
+++ b/backup.py
@@ -8,73 +8,60 @@ from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, P
from logger import logger
-async def backup_database():
- from bot import bot
+async def backup_database() -> Exception | None:
+ backup_file_path, exception = _create_database_backup()
+
+ if exception:
+ logger.error(f"Ошибка при создании бэкапа базы данных: {exception}")
+ return exception
try:
- if backup_file_path := _create_database_backup():
- await _send_backup_to_admin(bot, backup_file_path)
- _cleanup_old_backups()
+ await _send_backup_to_admins(backup_file_path)
+ exception = _cleanup_old_backups()
+
+ if exception:
+ logger.error(f"Ошибка при удалении старых бэкапов базы данных: {exception}")
+ return exception
+
+ return None
except Exception as e:
- logger.error(f"Ошибка при создании или отправке бэкапа: {e}")
+ logger.error(f"Ошибка при отправке бэкапа базы данных: {e}")
+ return e
-def _create_database_backup():
- USER = DB_USER
- HOST = PG_HOST
- PORT = PG_PORT
- BACKUP_DIR = BACK_DIR
- DATE = datetime.now().strftime("%Y-%m-%d-%H%M%S")
- BACKUP_FILE = f"{BACKUP_DIR}/{DB_NAME}-backup-{DATE}.sql"
-
- os.environ["PGPASSWORD"] = DB_PASSWORD
+def _create_database_backup() -> (str | None, Exception | None):
+ date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S")
+ filename = os.path.join(BACK_DIR, f"{DB_NAME}-backup-{date_formatted}.sql")
try:
+ os.environ["PGPASSWORD"] = DB_PASSWORD
+
subprocess.run(
[
"pg_dump",
"-U",
- USER,
+ DB_USER,
"-h",
- HOST,
+ PG_HOST,
"-p",
- PORT,
+ PG_PORT,
"-F",
"c",
"-f",
- BACKUP_FILE,
+ filename,
DB_NAME,
],
check=True,
)
- logger.info(f"Бэкап базы данных создан: {BACKUP_FILE}")
- return BACKUP_FILE
+ logger.info(f"Бэкап базы данных создан: {filename}")
+ return filename, None
except subprocess.CalledProcessError as e:
- logger.error(f"Ошибка при создании бэкапа базы данных: {e}")
- return None
+ return None, e
finally:
del os.environ["PGPASSWORD"]
-async def _send_backup_to_admin(bot, backup_file_path):
- try:
- with open(backup_file_path, "rb") as backup_file:
- backup_input_file = BufferedInputFile(
- backup_file.read(), filename=os.path.basename(backup_file_path)
- )
- admin_ids: int | list[int] = ADMIN_ID
- if isinstance(admin_ids, list):
- for id in admin_ids:
- await bot.send_document(id, backup_input_file)
- logger.info(f"Бэкап базы данных отправлен админу: {id}")
- else:
- await bot.send_document(admin_ids, backup_input_file)
- logger.info(f"Бэкап базы данных отправлен админу: {ADMIN_ID}")
- except Exception as e:
- logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
-
-
-def _cleanup_old_backups():
+def _cleanup_old_backups() -> None | Exception:
try:
subprocess.run(
[
@@ -94,10 +81,30 @@ def _cleanup_old_backups():
check=True,
)
logger.info("Старые бэкапы удалены.")
+ return None
except subprocess.CalledProcessError as e:
- logger.error(f"Ошибка при удалении старых бэкапов: {e}")
+ return e
-async def create_backup_and_send_to_admins(xui):
+async def create_backup_and_send_to_admins(xui) -> None:
await xui.login()
await xui.database.export()
+
+
+async def _send_backup_to_admins(backup_file_path: str) -> None:
+ try:
+ from bot import bot
+ with open(backup_file_path, "rb") as backup_file:
+ backup_input_file = BufferedInputFile(
+ file=backup_file.read(),
+ filename=os.path.basename(backup_file_path)
+ )
+ admin_ids = ADMIN_ID if isinstance(ADMIN_ID, list) else [ADMIN_ID]
+ for admin_id in admin_ids:
+ await bot.send_document(
+ chat_id=admin_id,
+ document=backup_input_file
+ )
+ logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
+ except Exception as e:
+ logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
diff --git a/bot.py b/bot.py
index 715fb7b2..947f5b42 100644
--- a/bot.py
+++ b/bot.py
@@ -18,6 +18,8 @@ bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTM
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
+version = "4.0.0-preAlpha(14-dev)"
+
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
diff --git a/handlers/admin/__init__.py b/handlers/admin/__init__.py
index 39109027..cb91e6f3 100644
--- a/handlers/admin/__init__.py
+++ b/handlers/admin/__init__.py
@@ -2,16 +2,26 @@ __all__ = ("router",)
from aiogram import Router
+from .admin_backups import router as backups_router
+from .admin_bans import router as bans_router
from .admin_coupons import router as coupons_router
from .admin_panel import router as panel_router
+from .admin_restart import router as restart_router
+from .admin_sender import router as sender_router
from .admin_servers import router as servers_router
-from .admin_user_editor import router as user_editor_router
+from .admin_stats import router as stats_router
+from .admin_users import router as users_router
router = Router(name="admins_main_router")
router.include_routers(
panel_router,
servers_router,
+ users_router,
+ stats_router,
+ backups_router,
+ sender_router,
coupons_router,
- user_editor_router,
+ restart_router,
+ bans_router,
)
diff --git a/handlers/admin/admin_backups.py b/handlers/admin/admin_backups.py
new file mode 100644
index 00000000..51eee005
--- /dev/null
+++ b/handlers/admin/admin_backups.py
@@ -0,0 +1,35 @@
+from aiogram import F, Router
+from aiogram.types import CallbackQuery
+
+from backup import backup_database
+from filters.admin import IsAdminFilter
+from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
+
+router = Router()
+
+
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "backups"),
+ IsAdminFilter(),
+)
+async def handle_backups(
+ callback_query: CallbackQuery
+):
+ kb = build_admin_back_kb("management")
+
+ await callback_query.message.edit_text(
+ text="💾 Инициализация резервного копирования базы данных...",
+ reply_markup=kb
+ )
+
+ exception = await backup_database()
+
+ if exception:
+ text = f"❌ Ошибка при создании резервной копии: {exception}"
+ else:
+ text = "✅ Резервная копия успешно создана и отправлена администраторам."
+
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=kb
+ )
diff --git a/handlers/admin/admin_bans.py b/handlers/admin/admin_bans.py
new file mode 100644
index 00000000..a515a14e
--- /dev/null
+++ b/handlers/admin/admin_bans.py
@@ -0,0 +1,107 @@
+from typing import Any
+
+from aiogram import F, Router
+from aiogram.types import CallbackQuery, BufferedInputFile
+
+from database import delete_user_data
+from filters.admin import IsAdminFilter
+from keyboards.admin.bans_kb import build_bans_kb
+from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
+
+router = Router()
+
+
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "bans"),
+ IsAdminFilter(),
+)
+async def handle_bans(
+ callback_query: CallbackQuery
+):
+ text = (
+ "🚫 Заблокировавшие бота"
+ "\n\nЗдесь можно просматривать и удалять пользователей, которые забанили вашего бота!"
+ )
+
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=build_bans_kb(),
+ )
+
+
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "bans_export"),
+ IsAdminFilter(),
+)
+async def handle_bans_export(
+ callback_query: CallbackQuery,
+ session: Any
+):
+ kb = build_admin_back_kb("management")
+
+ try:
+ banned_users = await session.fetch("SELECT tg_id, blocked_at FROM blocked_users")
+
+ import csv
+ import io
+
+ csv_output = io.StringIO()
+ writer = csv.writer(csv_output)
+ writer.writerow(["tg_id", "blocked_at"])
+ for user in banned_users:
+ writer.writerow([user["tg_id"], user["blocked_at"]])
+
+ csv_output.seek(0)
+
+ document = BufferedInputFile(
+ file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv"
+ )
+
+ await callback_query.message.answer_document(
+ document=document,
+ caption="📥 Экспорт пользователей, заблокировавших бота в CSV",
+ )
+ except Exception as e:
+ await callback_query.message.answer(
+ text=f"❗ Произошла ошибка при экспорте: {e}",
+ reply_markup=kb,
+ )
+
+
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "bans_delete_banned"),
+ IsAdminFilter(),
+)
+async def handle_bans_delete_banned(
+ callback_query: CallbackQuery,
+ session: Any
+):
+ kb = build_admin_back_kb("bans")
+
+ try:
+ blocked_users = await session.fetch("SELECT tg_id FROM blocked_users")
+ blocked_ids = [record["tg_id"] for record in blocked_users]
+
+ if not blocked_ids:
+ await callback_query.message.answer(
+ text="📂 Нет заблокировавших пользователей для удаления.",
+ reply_markup=kb,
+ )
+ return
+
+ for tg_id in blocked_ids:
+ await delete_user_data(session, tg_id)
+
+ await session.execute(
+ "DELETE FROM blocked_users WHERE tg_id = ANY($1)", blocked_ids
+ )
+
+ await callback_query.message.answer(
+ text=f"🗑️ Удалены данные о {len(blocked_ids)} пользователях и связанных записях.",
+ reply_markup=kb,
+ )
+ except Exception as e:
+ await callback_query.message.answer(
+ text=f"❗ Произошла ошибка при удалении записей: {e}",
+ reply_markup=kb,
+ )
diff --git a/handlers/admin/admin_coupons.py b/handlers/admin/admin_coupons.py
index 27e3071e..d1a34644 100644
--- a/handlers/admin/admin_coupons.py
+++ b/handlers/admin/admin_coupons.py
@@ -3,56 +3,136 @@ from typing import Any
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
-from aiogram.types import InlineKeyboardButton
-from aiogram.utils.keyboard import InlineKeyboardBuilder
-from database import create_coupon, delete_coupon_from_db, get_all_coupons
+from database import delete_coupon_from_db, get_all_coupons, create_coupon
from filters.admin import IsAdminFilter
+from keyboards.admin.coupons_kb import build_coupons_kb, build_coupons_list_kb, AdminCouponDeleteCallback
+from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
from logger import logger
+router = Router()
+
class AdminCouponsState(StatesGroup):
waiting_for_coupon_data = State()
-router = Router()
-
-
-@router.callback_query(F.data == "coupons_editor", IsAdminFilter())
-async def show_coupon_management_menu(
- callback_query: types.CallbackQuery, state: FSMContext
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "coupons"),
+ IsAdminFilter(),
+)
+async def handle_coupons(
+ callback_query: types.CallbackQuery,
):
- await state.clear()
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="➕ Создать купон", callback_data="create_coupon")
- )
- builder.row(InlineKeyboardButton(text="Купоны", callback_data="coupons"))
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
- await callback_query.message.answer(
- "🛠 Меню управления купонами:", reply_markup=builder.as_markup()
+ await callback_query.message.edit_text(
+ text="🛠 Меню управления купонами:",
+ reply_markup=build_coupons_kb()
)
-@router.callback_query(F.data == "coupons", IsAdminFilter())
-async def show_coupon_list(callback_query: types.CallbackQuery, session: Any):
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "coupons_create"),
+ IsAdminFilter(),
+)
+async def handle_coupons_create(
+ callback_query: types.CallbackQuery,
+ state: FSMContext
+):
+ text = (
+ "🎫 Введите данные для создания купона в формате:\n\n"
+ "📝 код 💰 сумма 🔢 лимит\n\n"
+ "Пример: 'COUPON1 50 5' 👈\n\n"
+ )
+
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=build_admin_back_kb("coupons"),
+ )
+ await state.set_state(AdminCouponsState.waiting_for_coupon_data)
+
+
+@router.message(
+ AdminCouponsState.waiting_for_coupon_data,
+ IsAdminFilter()
+)
+async def handle_coupon_data_input(
+ message: types.Message,
+ state: FSMContext,
+ session: Any
+):
+ text = message.text.strip()
+ parts = text.split()
+
+ kb = build_admin_back_kb("coupons")
+
+ if len(parts) != 3:
+ text = (
+ "❌ Некорректный формат! 📝 Пожалуйста, введите данные в формате:\n"
+ "🏷️ код 💰 сумма 🔢 лимит\n"
+ "Пример: 'COUPON1 50 5' 👈"
+ )
+
+ await message.answer(
+ text=text,
+ reply_markup=kb,
+ )
+ return
+
+ try:
+ coupon_code = parts[0]
+ coupon_amount = float(parts[1])
+ usage_limit = int(parts[2])
+ except ValueError:
+ text = (
+ "⚠️ Проверьте правильность введенных данных!\n"
+ "💱 Сумма должна быть числом, а лимит — целым числом."
+ )
+
+ await message.answer(
+ text=text,
+ reply_markup=kb,
+ )
+ return
+
+ try:
+ await create_coupon(coupon_code, coupon_amount, usage_limit, session)
+
+ text = (
+ f"✅ Купон с кодом {coupon_code} успешно создан!\n"
+ f"💰 Сумма: {coupon_amount} рублей \n"
+ f"🔢 Лимит использования: {usage_limit} раз"
+ )
+
+ await message.answer(
+ text=text,
+ reply_markup=kb
+ )
+ await state.clear()
+
+ except Exception as e:
+ logger.error(f"Ошибка при создании купона: {e}")
+
+
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "coupons_list"),
+ IsAdminFilter(),
+)
+async def handle_coupons_list(
+ callback_query: types.CallbackQuery,
+ session: Any
+):
try:
coupons = await get_all_coupons(session)
if not coupons:
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
- )
-
- await callback_query.message.answer(
- "❌ На данный момент нет доступных купонов. 🚫\nВы можете вернуться в меню управления. 🔙",
- reply_markup=builder.as_markup(),
+ await callback_query.message.edit_text(
+ text="❌ На данный момент нет доступных купонов!",
+ reply_markup=build_admin_back_kb("coupons"),
)
return
+ kb = build_coupons_list_kb(coupons)
coupon_list = "📜 Список всех купонов:\n\n"
- builder = InlineKeyboardBuilder()
for coupon in coupons:
coupon_list += (
@@ -62,103 +142,36 @@ async def show_coupon_list(callback_query: types.CallbackQuery, session: Any):
f"✅ Использовано: {coupon['usage_count']} раз\n\n"
)
- builder.row(
- InlineKeyboardButton(
- text=f"❌ Удалить {coupon['code']}",
- callback_data=f"delete_coupon_{coupon['code']}",
- )
- )
-
- builder.row(
- InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
- )
- await callback_query.message.answer(
- coupon_list, reply_markup=builder.as_markup()
+ await callback_query.message.edit_text(
+ text=coupon_list,
+ reply_markup=kb
)
except Exception as e:
logger.error(f"Ошибка при получении списка купонов: {e}")
-@router.callback_query(F.data.startswith("delete_coupon_"), IsAdminFilter())
-async def handle_delete_coupon(callback_query: types.CallbackQuery, session: Any):
- coupon_code = callback_query.data[len("delete_coupon_") :]
+@router.callback_query(
+ AdminCouponDeleteCallback.filter(),
+ IsAdminFilter(),
+)
+async def handle_coupon_delete(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminCouponDeleteCallback,
+ session: Any
+):
+ coupon_code = callback_data.coupon_code
try:
result = await delete_coupon_from_db(coupon_code, session)
if result:
- await show_coupon_list(callback_query, session)
+ await handle_coupons_list(callback_query, session)
else:
- await callback_query.message.answer(
- f"❌ Купон с кодом {coupon_code} не найден.",
+ await callback_query.message.edit_text(
+ text=f"❌ Купон с кодом {coupon_code} не найден.",
)
- await show_coupon_list(callback_query, session)
+ await handle_coupons_list(callback_query, session)
except Exception as e:
logger.error(f"Ошибка при удалении купона: {e}")
-
-
-@router.callback_query(F.data == "create_coupon", IsAdminFilter())
-async def handle_create_coupon(callback_query: types.CallbackQuery, state: FSMContext):
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor"))
-
- await callback_query.message.answer(
- "🎫 Введите данные для создания купона в формате:\n\n"
- "📝 код 💰 сумма 🔢 лимит\n\n"
- "Пример: 'COUPON1 50 5' 👈\n\n",
- reply_markup=builder.as_markup(),
- )
- await state.set_state(AdminCouponsState.waiting_for_coupon_data)
-
-
-@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter())
-async def process_coupon_data(message: types.Message, state: FSMContext, session: Any):
- text = message.text.strip()
-
- parts = text.split()
-
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor"))
-
- if len(parts) != 3:
- await message.answer(
- "❌ Некорректный формат! 📝 Пожалуйста, введите данные в формате:\n"
- "🏷️ код 💰 сумма 🔢 лимит\n"
- "Пример: 'COUPON1 50 5' 👈",
- reply_markup=builder.as_markup(),
- )
- return
-
- try:
- coupon_code = parts[0]
- coupon_amount = float(parts[1])
- usage_limit = int(parts[2])
- except ValueError:
- await message.answer(
- "⚠️ Проверьте правильность введенных данных!\n"
- "💱 Сумма должна быть числом, 🔢 а лимит — целым числом.",
- reply_markup=builder.as_markup(),
- )
- return
-
- try:
- await create_coupon(coupon_code, coupon_amount, usage_limit, session)
-
- result_message = (
- f"✅ Купон с кодом {coupon_code} успешно создан! 🎉\n"
- f"Сумма: {coupon_amount} рублей 💰\n"
- f"Лимит использования: {usage_limit} раз 🔢."
- )
-
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
- )
-
- await message.answer(result_message, reply_markup=builder.as_markup())
- await state.clear()
-
- except Exception as e:
- logger.error(f"Ошибка при создании купона: {e}")
diff --git a/handlers/admin/admin_panel.py b/handlers/admin/admin_panel.py
index 23e14fe6..a5594caa 100644
--- a/handlers/admin/admin_panel.py
+++ b/handlers/admin/admin_panel.py
@@ -1,627 +1,63 @@
-import subprocess
-from datetime import datetime
-from io import BytesIO
-from typing import Any
-
-import asyncpg
from aiogram import F, Router, types
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
-from aiogram.fsm.state import State, StatesGroup
-from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton
-from aiogram.utils.keyboard import InlineKeyboardBuilder
+from aiogram.types import CallbackQuery
-from backup import backup_database
-from bot import bot
-from config import DATABASE_URL
-from database import delete_user_data
+from bot import version
from filters.admin import IsAdminFilter
-from logger import logger
+from keyboards.admin.panel_kb import build_panel_kb, AdminPanelCallback, build_management_kb
router = Router()
-class UserEditorState(StatesGroup):
- waiting_for_tg_id = State()
- displaying_user_info = State()
- waiting_for_restart_confirmation = State()
- waiting_for_message = State()
-
-
-@router.callback_query(F.data == "admin", IsAdminFilter())
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "admin"),
+ IsAdminFilter()
+)
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
- await handle_admin_message(callback_query.message, state)
-
-
-@router.message(Command("admin"), F.data == "admin", IsAdminFilter())
-async def handle_admin_message(message: types.Message, state: FSMContext):
- await state.clear()
-
- BOT_VERSION = "4.0.0-Alpha(21)"
-
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="📊 Статистика пользователей", callback_data="user_stats"
- )
+ text = (
+ "🤖 Панель администратора"
+ f"\n📌 Версия бота: {version}"
)
- builder.row(
- InlineKeyboardButton(
- text="👥 Управление пользователями", callback_data="user_editor"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="🖥️ Управление серверами", callback_data="servers_editor"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="🎟️ Управление купонами", callback_data="coupons_editor"
- )
- )
- builder.row(
- InlineKeyboardButton(text="📢 Массовая рассылка", callback_data="send_to")
- )
- builder.row(
- InlineKeyboardButton(text="🤖 Управление Ботом", callback_data="bot_management")
- )
- builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
- await message.answer(
- f"🤖 Панель администратора\n\nВерсия бота: {BOT_VERSION}",
- reply_markup=builder.as_markup()
- )
-
-
-@router.callback_query(F.data == "bot_management")
-async def handle_bot_management(callback_query: types.CallbackQuery):
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="💾 Создать резервную копию", callback_data="backups")
- )
- builder.row(
- InlineKeyboardButton(text="🔄 Перезагрузить бота", callback_data="restart_bot")
- )
- builder.row(InlineKeyboardButton(text="🚫 Баны", callback_data="ban_user"))
- builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="admin"))
- await callback_query.message.answer(
- "🤖 Управление ботом",
- reply_markup=builder.as_markup(),
- )
-
-
-@router.callback_query(F.data == "user_stats", IsAdminFilter())
-async def user_stats_menu(callback_query: CallbackQuery, session: Any):
- try:
- total_users = await session.fetchval("SELECT COUNT(*) FROM users")
- total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
- total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
-
- total_payments_today = await session.fetchval(
- "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE"
- )
- total_payments_week = await session.fetchval(
- "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
- )
- total_payments_month = await session.fetchval(
- "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)"
- )
- total_payments_all_time = await session.fetchval(
- "SELECT COALESCE(SUM(amount), 0) FROM payments"
- )
-
- registrations_today = await session.fetchval(
- "SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE"
- )
- registrations_week = await session.fetchval(
- "SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('week', CURRENT_DATE)"
- )
- registrations_month = await session.fetchval(
- "SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('month', CURRENT_DATE)"
- )
-
- users_updated_today = await session.fetchval(
- "SELECT COUNT(*) FROM users WHERE updated_at >= CURRENT_DATE"
- )
-
- active_keys = await session.fetchval(
- "SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
- int(datetime.utcnow().timestamp() * 1000),
- )
- expired_keys = total_keys - active_keys
-
- stats_message = (
- f"📊 Подробная статистика проекта:\n\n"
- f"👥 Пользователи:\n"
- f" 📅 За день: {registrations_today}\n"
- f" 📆 За неделю: {registrations_week}\n"
- f" 📆 За месяц: {registrations_month}\n"
- f" 🌐 За все время: {total_users}\n\n"
- f"🌟 Активные пользователи:\n"
- f" 🌟 Активных сегодня: {users_updated_today}\n\n"
- f"👥 Рефералы:\n"
- f" 🤝 Всего привлечено: {total_referrals}\n\n"
- f"🔑 Ключи:\n"
- f" 🌈 Всего сгенерировано: {total_keys}\n"
- f" ✅ Действующих: {active_keys}\n"
- f" ❌ Просроченных: {expired_keys}\n\n"
- f"💰 Финансовая статистика:\n"
- f" 📅 За день: {total_payments_today} ₽\n"
- f" 📆 За неделю: {total_payments_week} ₽\n"
- f" 📆 За месяц: {total_payments_month} ₽\n"
- f" 🏦 За все время: {total_payments_all_time} ₽\n"
- )
-
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="🔄 Обновить", callback_data="user_stats")
- )
- builder.row(
- InlineKeyboardButton(
- text="📥 Выгрузить пользователей в CSV",
- callback_data="export_users_csv",
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="📥 Выгрузить оплаты в CSV", callback_data="export_payments_csv"
- )
- )
- builder.row(
- InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin")
- )
-
- await callback_query.message.answer(
- stats_message, reply_markup=builder.as_markup()
- )
- except Exception as e:
- logger.error(f"Error in user_stats_menu: {e}")
-
-
-@router.callback_query(F.data == "export_users_csv", IsAdminFilter())
-async def export_users_csv(callback_query: CallbackQuery, session: Any):
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats"))
- try:
- users = await session.fetch(
- """
- SELECT
- u.tg_id,
- u.username,
- u.first_name,
- u.last_name,
- u.language_code,
- u.is_bot,
- c.balance,
- c.trial
- FROM users u
- LEFT JOIN connections c ON u.tg_id = c.tg_id
- """
- )
-
- if not users:
- await callback_query.message.answer(
- "📭 Нет пользователей для экспорта.", reply_markup=builder.as_markup()
- )
- return
-
- csv_data = (
- "tg_id,username,first_name,last_name,language_code,is_bot,balance,trial\n"
- )
- for user in users:
- csv_data += f"{user['tg_id']},{user['username']},{user['first_name']},{user['last_name']},{user['language_code']},{user['is_bot']},{user['balance']},{user['trial']}\n"
-
- file_name = BytesIO(csv_data.encode("utf-8-sig"))
- file_name.seek(0)
-
- file = BufferedInputFile(file_name.getvalue(), filename="users_export.csv")
-
- await callback_query.message.answer_document(
- file,
- caption="📥 Экспорт пользователей в CSV",
- reply_markup=builder.as_markup(),
- )
- file_name.close()
-
- except Exception as e:
- logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
- await callback_query.message.answer(
- "❗ Произошла ошибка при экспорте пользователей.",
- reply_markup=builder.as_markup(),
- )
-
-
-@router.callback_query(F.data == "export_payments_csv", IsAdminFilter())
-async def export_payments_csv(callback_query: CallbackQuery, session: Any):
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats"))
- try:
- payments = await session.fetch(
- """
- SELECT
- u.tg_id,
- u.username,
- u.first_name,
- u.last_name,
- p.amount,
- p.payment_system,
- p.status,
- p.created_at
- FROM users u
- JOIN payments p ON u.tg_id = p.tg_id
- """
- )
-
- if not payments:
- await callback_query.message.answer(
- "📭 Нет платежей для экспорта.", reply_markup=builder.as_markup()
- )
- return
-
- csv_data = "tg_id,username,first_name,last_name,amount,payment_system,status,created_at\n" # Заголовки CSV
- for payment in payments:
- csv_data += f"{payment['tg_id']},{payment['username']},{payment['first_name']},{payment['last_name']},{payment['amount']},{payment['payment_system']},{payment['status']},{payment['created_at']}\n"
-
- file_name = BytesIO(csv_data.encode("utf-8-sig"))
- file_name.seek(0)
-
- file = BufferedInputFile(file_name.getvalue(), filename="payments_export.csv")
-
- await callback_query.message.answer_document(
- file, caption="📥 Экспорт платежей в CSV", reply_markup=builder.as_markup()
- )
- file_name.close()
-
- except Exception as e:
- logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
- await callback_query.message.answer(
- "❗ Произошла ошибка при экспорте платежей.",
- reply_markup=builder.as_markup(),
- )
-
-
-@router.callback_query(F.data == "send_to", IsAdminFilter())
-async def handle_send_to_all(callback_query: CallbackQuery, state: FSMContext):
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="📢 Отправить всем", callback_data="send_to_all")
- )
- builder.row(
- InlineKeyboardButton(
- text="📢 Отправить с подпиской", callback_data="send_to_subscribed"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="📢 Отправить без подписки", callback_data="send_to_unsubscribed"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="📢 Рассылка по кластеру", callback_data="send_to_cluster"
- )
- )
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
- await callback_query.message.answer(
- "✍️ Выберите группу пользователей и введите текст сообщения для рассылки:",
- reply_markup=builder.as_markup(),
- )
-
-
-@router.callback_query(F.data == "send_to_all", IsAdminFilter())
-async def handle_send_to_all(callback_query: CallbackQuery, state: FSMContext):
- await state.update_data(send_to="all")
- await callback_query.message.answer(
- "✍️ Введите текст сообщения для рассылки всем пользователям:"
- )
- await state.set_state(UserEditorState.waiting_for_message)
-
-
-@router.callback_query(F.data == "send_to_subscribed", IsAdminFilter())
-async def handle_send_to_subscribed(callback_query: CallbackQuery, state: FSMContext):
- await state.update_data(send_to="subscribed")
- await callback_query.message.answer(
- "✍️ Введите текст сообщения для рассылки пользователям с активной подпиской:"
- )
- await state.set_state(UserEditorState.waiting_for_message)
-
-
-@router.callback_query(F.data == "send_to_unsubscribed", IsAdminFilter())
-async def handle_send_to_unsubscribed(callback_query: CallbackQuery, state: FSMContext):
- await state.update_data(send_to="unsubscribed")
- await callback_query.message.answer(
- "✍️ Введите текст сообщения для рассылки пользователям без активной подписки:"
- )
- await state.set_state(UserEditorState.waiting_for_message)
-
-
-@router.callback_query(F.data == "send_to_cluster", IsAdminFilter())
-async def handle_send_to_cluster(
- callback_query: CallbackQuery, state: FSMContext, session: Any
-):
- clusters = await session.fetch("SELECT DISTINCT cluster_name FROM servers")
-
- builder = InlineKeyboardBuilder()
- for cluster in clusters:
- builder.row(
- InlineKeyboardButton(
- text=f"🌐 {cluster['cluster_name']}",
- callback_data=f"send_cluster|{cluster['cluster_name']}",
- )
- )
-
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="send_to"))
- await callback_query.message.answer(
- "✍️ Выберите кластер для рассылки сообщений:",
- reply_markup=builder.as_markup(),
- )
-
-
-@router.callback_query(F.data.startswith("send_cluster|"), IsAdminFilter())
-async def handle_send_cluster(callback_query: CallbackQuery, state: FSMContext):
- cluster_name = callback_query.data.split("|")[1]
- await state.update_data(send_to="cluster", cluster_name=cluster_name)
- await callback_query.message.answer(
- f"✍️ Введите текст сообщения для рассылки пользователям кластера {cluster_name}:"
- )
- await state.set_state(UserEditorState.waiting_for_message)
-
-
-@router.message(UserEditorState.waiting_for_message, IsAdminFilter())
-async def process_message_to_all(
- message: types.Message, state: FSMContext, session: Any
-):
- text_message = message.text
-
- try:
- state_data = await state.get_data()
- send_to = state_data.get("send_to", "all")
-
- if send_to == "all":
- tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections")
- elif send_to == "subscribed":
- tg_ids = await session.fetch(
- """
- SELECT DISTINCT c.tg_id
- FROM connections c
- JOIN keys k ON c.tg_id = k.tg_id
- WHERE k.expiry_time > $1
- """,
- int(datetime.utcnow().timestamp() * 1000),
- )
- elif send_to == "unsubscribed":
- tg_ids = await session.fetch(
- """
- SELECT c.tg_id
- FROM connections c
- LEFT JOIN keys k ON c.tg_id = k.tg_id
- GROUP BY c.tg_id
- HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1
- """,
- int(datetime.utcnow().timestamp() * 1000),
- )
- elif send_to == "cluster":
- cluster_name = state_data.get("cluster_name")
- tg_ids = await session.fetch(
- """
- SELECT DISTINCT c.tg_id
- FROM connections c
- JOIN keys k ON c.tg_id = k.tg_id
- JOIN servers s ON k.server_id = s.cluster_name
- WHERE s.cluster_name = $1
- """,
- cluster_name,
- )
-
- total_users = len(tg_ids)
- success_count = 0
- error_count = 0
-
- for record in tg_ids:
- tg_id = record["tg_id"]
- try:
- await bot.send_message(chat_id=tg_id, text=text_message)
- success_count += 1
- except Exception as e:
- error_count += 1
- logger.error(
- f"❌ Ошибка при отправке сообщения пользователю {tg_id}: {e}"
- )
-
- await message.answer(
- f"📤 Рассылка завершена:\n"
- f"👥 Всего пользователей: {total_users}\n"
- f"✅ Успешно отправлено: {success_count}\n"
- f"❌ Не доставлено: {error_count}"
- )
- except Exception as e:
- logger.error(f"❗ Ошибка при подключении к базе данных: {e}")
await state.clear()
-
-
-@router.callback_query(F.data == "backups", IsAdminFilter())
-async def handle_backup(callback_query: CallbackQuery, state: FSMContext):
- await callback_query.message.answer(
- "💾 Инициализация резервного копирования базы данных..."
- )
- await backup_database()
- await callback_query.message.answer(
- "✅ Резервная копия успешно создана и отправлена администратору."
- )
-
-
-@router.callback_query(F.data == "restart_bot", IsAdminFilter())
-async def handle_restart(callback_query: CallbackQuery, state: FSMContext):
- await state.set_state(UserEditorState.waiting_for_restart_confirmation)
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="✅ Да, перезапустить", callback_data="confirm_restart"
- ),
- InlineKeyboardButton(text="❌ Нет, отмена", callback_data="admin"),
- )
- builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin"))
- await callback_query.message.answer(
- "🤔 Вы уверены, что хотите перезапустить бота?",
- reply_markup=builder.as_markup(),
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=build_panel_kb()
)
@router.callback_query(
- F.data == "confirm_restart",
- UserEditorState.waiting_for_restart_confirmation,
- IsAdminFilter(),
+ F.data == "admin",
+ IsAdminFilter()
)
-async def confirm_restart_bot(callback_query: CallbackQuery, state: FSMContext):
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin"))
- try:
- subprocess.run(
- ["sudo", "systemctl", "restart", "bot.service"],
- check=True,
- capture_output=True,
- text=True,
- )
- await state.clear()
- await callback_query.message.answer(
- "🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup()
- )
- except subprocess.CalledProcessError:
- await callback_query.message.answer(
- "🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup()
- )
- except Exception as e:
- await callback_query.message.answer(
- f"⚠️ Ошибка при перезагрузке бота: {e.stderr}",
- reply_markup=builder.as_markup(),
- )
+async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
+ await handle_admin_message(callback_query.message, state)
-@router.callback_query(F.data == "user_editor", IsAdminFilter())
-async def user_editor_menu(callback_query: CallbackQuery):
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="🔍 Поиск по названию ключа",
- callback_data="search_by_key_name",
- )
+@router.message(
+ Command("admin"),
+ IsAdminFilter()
+)
+async def handle_admin_message(message: types.Message, state: FSMContext):
+ text = (
+ "🤖 Панель администратора"
+ f"\n📌 Версия бота: {version}"
)
- builder.row(
- InlineKeyboardButton(
- text="🆔 Поиск по Telegram ID", callback_data="search_by_tg_id"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="🌐 Поиск по Username", callback_data="search_by_username"
- )
- )
- builder.row(InlineKeyboardButton(text="🔙 Вернуться назад", callback_data="admin"))
- await callback_query.message.answer(
- "👇 Выберите способ поиска пользователя:", reply_markup=builder.as_markup()
+
+ await state.clear()
+ await message.answer(
+ text=text,
+ reply_markup=build_panel_kb()
)
-@router.callback_query(F.data == "ban_user")
-async def handle_ban_user(callback_query: types.CallbackQuery):
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="📄 Выгрузить в CSV", callback_data="export_to_csv")
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "management"),
+ IsAdminFilter()
+)
+async def handle_management(callback_query: CallbackQuery):
+ await callback_query.message.edit_text(
+ text="🤖 Управление ботом",
+ reply_markup=build_management_kb(),
)
- builder.row(
- InlineKeyboardButton(
- text="🗑️ Удалить из БД", callback_data="delete_banned_users"
- )
- )
- builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management"))
- await callback_query.message.answer(
- "🚫 Заблокировавшие бота\n\n"
- "Здесь можно просматривать и удалять пользователей, которые забанили вашего бота!",
- reply_markup=builder.as_markup(),
- )
-
-
-@router.callback_query(F.data == "export_to_csv")
-async def export_banned_users_to_csv(callback_query: types.CallbackQuery):
- conn = await asyncpg.connect(DATABASE_URL)
- try:
- banned_users = await conn.fetch("SELECT tg_id, blocked_at FROM blocked_users")
-
- import csv
- import io
-
- csv_output = io.StringIO()
- writer = csv.writer(csv_output)
- writer.writerow(["tg_id", "blocked_at"])
- for user in banned_users:
- writer.writerow([user["tg_id"], user["blocked_at"]])
-
- csv_output.seek(0)
-
- document = BufferedInputFile(
- file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv"
- )
-
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management")
- )
-
- await callback_query.message.answer_document(
- document=document,
- caption="📄 Список заблокировавших бота пользователей",
- reply_markup=builder.as_markup(),
- )
- except Exception as e:
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management")
- )
- await callback_query.message.answer(
- text=f"Ошибка при выгрузке CSV: {e}",
- reply_markup=builder.as_markup(),
- )
- finally:
- await conn.close()
-
-
-@router.callback_query(F.data == "delete_banned_users")
-async def delete_banned_users(callback_query: types.CallbackQuery):
- conn = await asyncpg.connect(DATABASE_URL)
- try:
- blocked_users = await conn.fetch("SELECT tg_id FROM blocked_users")
- blocked_ids = [record["tg_id"] for record in blocked_users]
-
- if not blocked_ids:
- await callback_query.message.answer(
- "📂 Нет заблокировавших пользователей для удаления."
- )
- return
-
- for tg_id in blocked_ids:
- await delete_user_data(conn, tg_id)
-
- await conn.execute(
- "DELETE FROM blocked_users WHERE tg_id = ANY($1)", blocked_ids
- )
-
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management")
- )
- await callback_query.message.answer(
- text=f"🗑️ Удалено данные о {len(blocked_ids)} пользователях и связанных записях.",
- reply_markup=builder.as_markup(),
- )
- except Exception as e:
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management")
- )
- await callback_query.message.answer(
- text=f"Ошибка при удалении записей: {e}",
- reply_markup=builder.as_markup(),
- )
- finally:
- await conn.close()
diff --git a/handlers/admin/admin_restart.py b/handlers/admin/admin_restart.py
new file mode 100644
index 00000000..157bf13b
--- /dev/null
+++ b/handlers/admin/admin_restart.py
@@ -0,0 +1,49 @@
+import subprocess
+
+from aiogram import F, Router
+from aiogram.types import CallbackQuery
+
+from filters.admin import IsAdminFilter
+from keyboards.admin.panel_kb import build_restart_kb, AdminPanelCallback, build_admin_back_kb
+
+router = Router()
+
+
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "restart"),
+ IsAdminFilter(),
+)
+async def handle_restart(callback_query: CallbackQuery):
+ await callback_query.message.edit_text(
+ text="🤔 Вы уверены, что хотите перезагрузить бота?",
+ reply_markup=build_restart_kb(),
+ )
+
+
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "restart_confirm"),
+ IsAdminFilter(),
+)
+async def handle_restart_confirm(callback_query: CallbackQuery):
+ kb = build_admin_back_kb()
+ try:
+ subprocess.run(
+ ["sudo", "systemctl", "restart", "bot.service"],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ await callback_query.message.edit_text(
+ text="🔄 Бот успешно перезагружен!",
+ reply_markup=kb
+ )
+ except subprocess.CalledProcessError:
+ await callback_query.message.edit_text(
+ text="🔄 Бот успешно перезагружен!",
+ reply_markup=kb
+ )
+ except Exception as e:
+ await callback_query.message.edit_text(
+ text=f"⚠️ Ошибка при перезагрузке бота: {e.stderr}",
+ reply_markup=kb
+ )
diff --git a/handlers/admin/admin_sender.py b/handlers/admin/admin_sender.py
new file mode 100644
index 00000000..e82598bd
--- /dev/null
+++ b/handlers/admin/admin_sender.py
@@ -0,0 +1,112 @@
+from datetime import datetime
+from typing import Any
+
+from aiogram import F, Router, types
+from aiogram.fsm.context import FSMContext
+from aiogram.fsm.state import State, StatesGroup
+from aiogram.types import CallbackQuery
+
+from filters.admin import IsAdminFilter
+from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
+from keyboards.admin.sender_kb import build_sender_kb, AdminSenderCallback
+from logger import logger
+
+router = Router()
+
+
+class AdminSender(StatesGroup):
+ waiting_for_message = State()
+
+
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "sender"),
+ IsAdminFilter(),
+)
+async def handle_sender(
+ callback_query: CallbackQuery
+):
+ await callback_query.message.edit_text(
+ text="✍️ Выберите группу пользователей для рассылки:",
+ reply_markup=build_sender_kb(),
+ )
+
+
+@router.callback_query(
+ AdminSenderCallback.filter(),
+ IsAdminFilter(),
+)
+async def handle_sender_callback(
+ callback_query: CallbackQuery,
+ callback_data: AdminSenderCallback,
+ state: FSMContext
+):
+ await callback_query.message.edit_text(
+ text="✍️ Введите текст сообщения для рассылки:",
+ reply_markup=build_admin_back_kb("sender"),
+ )
+ await state.update_data(type=callback_data.type)
+ await state.set_state(AdminSender.waiting_for_message)
+
+
+@router.message(
+ AdminSender.waiting_for_message,
+ IsAdminFilter(),
+)
+async def handle_message_input(
+ message: types.Message,
+ state: FSMContext,
+ session: Any
+):
+ text_message = message.text
+
+ try:
+ state_data = await state.get_data()
+ send_to = state_data.get("type", "all")
+
+ if send_to == "subscribed":
+ tg_ids = await session.fetch("""
+ SELECT DISTINCT c.tg_id
+ FROM connections c
+ JOIN keys k ON c.tg_id = k.tg_id
+ WHERE k.expiry_time > $1
+ """, int(datetime.utcnow().timestamp() * 1000))
+ elif send_to == "unsubscribed":
+ tg_ids = await session.fetch("""
+ SELECT c.tg_id
+ FROM connections c
+ LEFT JOIN keys k ON c.tg_id = k.tg_id
+ GROUP BY c.tg_id
+ HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1
+ """, int(datetime.utcnow().timestamp() * 1000))
+ else:
+ tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections")
+
+ total_users = len(tg_ids)
+ success_count = 0
+
+ for record in tg_ids:
+ tg_id = record["tg_id"]
+ try:
+ await message.bot.send_message(
+ chat_id=tg_id,
+ text=text_message
+ )
+ success_count += 1
+ except Exception:
+ pass
+
+ text = (
+ f"📤 Рассылка завершена!"
+ f"\n\n👥 Всего пользователей: {total_users}"
+ f"\n✅ Доставлено: {success_count}"
+ f"\n❌ Не доставлено: {total_users - success_count}"
+ )
+
+ await message.answer(
+ text=text,
+ reply_markup=build_admin_back_kb("stats")
+ )
+ except Exception as e:
+ logger.error(f"❗ Ошибка при подключении к базе данных: {e}")
+
+ await state.clear()
diff --git a/handlers/admin/admin_servers.py b/handlers/admin/admin_servers.py
index 4fb6bb32..d190f1ee 100644
--- a/handlers/admin/admin_servers.py
+++ b/handlers/admin/admin_servers.py
@@ -1,24 +1,22 @@
-import asyncio
-
import asyncpg
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
-from aiogram.types import InlineKeyboardButton
-from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from backup import create_backup_and_send_to_admins
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL
from database import check_unique_server_name, get_servers_from_db
from filters.admin import IsAdminFilter
-from handlers.keys.key_utils import create_key_on_cluster
-from logger import logger
+from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
+from keyboards.admin.servers_kb import build_manage_server_kb, \
+ build_delete_server_kb, \
+ build_manage_cluster_kb, build_clusters_editor_kb, AdminServerEditorCallback
router = Router()
-class UserEditorState(StatesGroup):
+class AdminServersEditor(StatesGroup):
waiting_for_cluster_name = State()
waiting_for_api_url = State()
waiting_for_inbound_id = State()
@@ -26,112 +24,104 @@ class UserEditorState(StatesGroup):
waiting_for_subscription_url = State()
-@router.callback_query(F.data == "servers_editor", IsAdminFilter())
-async def handle_servers_editor(callback_query: types.CallbackQuery):
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "servers"),
+ IsAdminFilter(),
+)
+async def handle_servers(
+ callback_query: types.CallbackQuery
+):
servers = await get_servers_from_db()
- builder = InlineKeyboardBuilder()
-
- for cluster_name, cluster_servers in servers.items():
- builder.row(
- InlineKeyboardButton(
- text=f"⚙️ {cluster_name}", callback_data=f"manage_cluster|{cluster_name}"
- )
- )
-
- builder.row(
- InlineKeyboardButton(text="➕ Добавить кластер", callback_data="add_cluster")
- )
- builder.row(InlineKeyboardButton(text="🔙 Назад в админку", callback_data="admin"))
-
- await callback_query.message.answer(
+ text = (
"🔧 Управление кластерами\n\n"
"📌 Здесь вы можете добавить новый кластер.\n\n"
"🌐 Кластеры — это пространство серверов, в пределах которого создается подписка.\n"
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n"
- "⚠️ Важно: Кластеры удаляются автоматически, если удалить все серверы внутри них.\n\n",
- parse_mode="HTML",
- reply_markup=builder.as_markup(),
+ "⚠️ Важно: Кластеры удаляются автоматически, если удалить все серверы внутри них.\n\n"
+ )
+
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=build_clusters_editor_kb(servers),
)
-@router.callback_query(F.data == "add_cluster", IsAdminFilter())
-async def handle_add_cluster(callback_query: types.CallbackQuery, state: FSMContext):
- await callback_query.message.answer(
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "clusters_add"),
+ IsAdminFilter(),
+)
+async def handle_clusters_add(
+ callback_query: types.CallbackQuery,
+ state: FSMContext
+):
+ text = (
"🔧 Введите имя нового кластера:\n\n"
- "Имя кластера должно быть уникальным и на английском языке.\n"
- "Пример: cluster2 или us_east_1",
- parse_mode="HTML",
+ "Имя кластера должно быть уникальным!\n"
+ "Пример: cluster1 или us_east_1"
)
- await state.set_state(UserEditorState.waiting_for_cluster_name)
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=build_admin_back_kb("servers")
+ )
+
+ await state.set_state(AdminServersEditor.waiting_for_cluster_name)
-@router.message(UserEditorState.waiting_for_cluster_name, IsAdminFilter())
-async def handle_cluster_name_input(message: types.Message, state: FSMContext):
- cluster_name = message.text.strip()
-
- if cluster_name == "❌ Отменить":
- await state.clear()
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="🔧 Управление кластерами", callback_data="servers_editor"
- )
- )
+@router.message(
+ AdminServersEditor.waiting_for_cluster_name,
+ IsAdminFilter()
+)
+async def handle_cluster_name_input(
+ message: types.Message,
+ state: FSMContext
+):
+ if not message.text:
await message.answer(
- "Процесс создания кластера отменен. Вы вернулись в меню управления серверами.",
- reply_markup=builder.as_markup(),
+ text="❌ Имя кластера не может быть пустым. Попробуйте снова.",
+ reply_markup=build_admin_back_kb("servers")
)
return
- if not cluster_name:
- await message.answer("❌ Имя кластера не может быть пустым. Попробуйте снова.")
- return
-
+ cluster_name = message.text.strip()
await state.update_data(cluster_name=cluster_name)
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
+ text = (
+ f"Введите имя сервера для кластера {cluster_name}:\n\n"
+ "Рекомендуется указать локацию и номер сервера в имени.\n\n"
+ "Пример: de1, fra1, fi2"
)
await message.answer(
- f"Введите имя сервера для кластера {cluster_name}:\n\n"
- "Рекомендуется указать локацию сервера в имени.\n\n"
- "Пример: server-asia, server-europe",
- parse_mode="HTML",
- reply_markup=builder.as_markup(),
+ text=text,
+ reply_markup=build_admin_back_kb("servers"),
)
- await state.set_state(UserEditorState.waiting_for_server_name)
+
+ await state.set_state(AdminServersEditor.waiting_for_server_name)
-@router.message(UserEditorState.waiting_for_server_name, IsAdminFilter())
-async def handle_server_name_input(message: types.Message, state: FSMContext):
+@router.message(
+ AdminServersEditor.waiting_for_server_name,
+ IsAdminFilter()
+)
+async def handle_server_name_input(
+ message: types.Message,
+ state: FSMContext
+):
+ if not message.text:
+ await message.answer(
+ text="❌ Имя сервера не может быть пустым. Попробуйте снова.",
+ reply_markup=build_admin_back_kb("servers")
+ )
+ return
+
server_name = message.text.strip()
- if server_name == "❌ Отменить":
- await state.clear()
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="🔧 Управление кластерами", callback_data="servers_editor"
- )
- )
+ if not await check_unique_server_name(server_name):
await message.answer(
- "Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
- reply_markup=builder.as_markup(),
- )
- return
-
- if not server_name:
- await message.answer("❌ Имя сервера не может быть пустым. Попробуйте снова.")
- return
-
- server_unique = await check_unique_server_name(server_name)
- if not server_unique:
- await message.answer(
- "❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя."
+ text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.",
+ reply_markup=build_admin_back_kb("servers")
)
return
@@ -139,124 +129,107 @@ async def handle_server_name_input(message: types.Message, state: FSMContext):
cluster_name = user_data.get("cluster_name")
await state.update_data(server_name=server_name)
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
- )
-
- await message.answer(
+ text = (
f"Введите API URL для сервера {server_name} в кластере {cluster_name}:\n\n"
"API URL должен быть в следующем формате:\n\n"
"https://your_domain:port/panel_path\n\n"
- "URL должен быть без слэша на конце!\n",
- parse_mode="HTML",
- reply_markup=builder.as_markup(),
+ "URL должен быть без слэша на конце!\n"
)
- await state.set_state(UserEditorState.waiting_for_api_url)
+
+ await message.answer(
+ text=text,
+ reply_markup=build_admin_back_kb("servers"),
+ )
+
+ await state.set_state(AdminServersEditor.waiting_for_api_url)
-@router.message(UserEditorState.waiting_for_api_url, IsAdminFilter())
-async def handle_api_url_input(message: types.Message, state: FSMContext):
- api_url = message.text.strip()
-
- if api_url == "❌ Отменить":
- await state.clear()
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="🔧 Управление кластерами", callback_data="servers_editor"
- )
- )
+@router.message(
+ AdminServersEditor.waiting_for_api_url,
+ IsAdminFilter()
+)
+async def handle_api_url_input(
+ message: types.Message,
+ state: FSMContext
+):
+ if not message.text or not message.text.strip().startswith("https://"):
await message.answer(
- "Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
- reply_markup=builder.as_markup(),
+ text="❌ API URL должен начинаться с https://. Попробуйте снова.",
+ reply_markup=build_admin_back_kb("servers")
)
return
- if not api_url.startswith("https://"):
- await message.answer(
- "❌ API URL должен начинаться с https://. Попробуйте снова.",
- parse_mode="HTML",
- )
- return
-
- api_url = api_url.rstrip("/")
+ api_url = message.text.strip().rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(api_url=api_url)
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
- )
-
- await message.answer(
+ text = (
f"Введите subscription_url для сервера {server_name} в кластере {cluster_name}:\n\n"
"Subscription URL должен быть в следующем формате:\n\n"
"https://your_domain:port_sub/sub_path\n\n"
"URL должен быть без слэша и имени клиента на конце!\n"
- "Его можно увидеть в панели 3x-ui в информации о клиенте.",
- parse_mode="HTML",
- reply_markup=builder.as_markup(),
+ "Его можно увидеть в панели 3x-ui в информации о клиенте."
)
- await state.set_state(UserEditorState.waiting_for_subscription_url)
+
+ await message.answer(
+ text=text,
+ reply_markup=build_admin_back_kb("servers"),
+ )
+
+ await state.set_state(AdminServersEditor.waiting_for_subscription_url)
-@router.message(UserEditorState.waiting_for_subscription_url, IsAdminFilter())
-async def handle_subscription_url_input(message: types.Message, state: FSMContext):
- subscription_url = message.text.strip()
-
- if subscription_url == "❌ Отменить":
- await state.clear()
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="🔧 Управление кластерами", callback_data="servers_editor"
- )
- )
+@router.message(
+ AdminServersEditor.waiting_for_subscription_url,
+ IsAdminFilter()
+)
+async def handle_subscription_url_input(
+ message: types.Message,
+ state: FSMContext
+):
+ if not message.text or not message.text.strip().startswith("https://"):
await message.answer(
- "Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
- reply_markup=builder.as_markup(),
+ text="❌ subscription_url должен начинаться с https://. Попробуйте снова.",
+ reply_markup=build_admin_back_kb("servers")
)
return
- if not subscription_url.startswith("https://"):
- await message.answer(
- "❌ subscription_url должен начинаться с https://. Попробуйте снова.",
- parse_mode="HTML",
- )
- return
-
- subscription_url = subscription_url.rstrip("/")
+ subscription_url = message.text.strip().rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(subscription_url=subscription_url)
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
+ text = (
+ f"Введите inbound_id для сервера {server_name} в кластере {cluster_name}:\n\n"
+ "Это номер подключения vless в вашей панели 3x-ui. Обычно это 1 при чистой настройке по гайду.\n\n"
)
await message.answer(
- f"Введите inbound_id для сервера {server_name} в кластере {cluster_name}:\n\n"
- "Это номер подключения vless в вашей панели 3x-ui. Обычно это 1 при чистой настройке по гайду.\n\n",
- parse_mode="HTML",
- reply_markup=builder.as_markup(),
+ text=text,
+ reply_markup=build_admin_back_kb("servers"),
)
- await state.set_state(UserEditorState.waiting_for_inbound_id)
+ await state.set_state(AdminServersEditor.waiting_for_inbound_id)
-@router.message(UserEditorState.waiting_for_inbound_id, IsAdminFilter())
-async def handle_inbound_id_input(message: types.Message, state: FSMContext):
+@router.message(
+ AdminServersEditor.waiting_for_inbound_id,
+ IsAdminFilter()
+)
+async def handle_inbound_id_input(
+ message: types.Message,
+ state: FSMContext
+):
inbound_id = message.text.strip()
if not inbound_id.isdigit():
await message.answer(
- "❌ inbound_id должен быть числовым значением. Попробуйте снова."
+ text="❌ inbound_id должен быть числовым значением. Попробуйте снова.",
+ reply_markup=build_admin_back_kb("servers")
)
return
@@ -280,156 +253,62 @@ async def handle_inbound_id_input(message: types.Message, state: FSMContext):
)
await conn.close()
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="🔙 Назад к кластерам", callback_data="servers_editor"
- )
- )
-
await message.answer(
- f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
- reply_markup=builder.as_markup(),
+ text=f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
+ reply_markup=build_admin_back_kb("servers"),
)
await state.clear()
-@router.callback_query(F.data.startswith("manage_cluster|"), IsAdminFilter())
-async def handle_manage_cluster(callback_query: types.CallbackQuery, state: FSMContext):
- cluster_name = callback_query.data.split("|")[1]
+@router.callback_query(
+ AdminServerEditorCallback.filter(F.action == "clusters_manage"),
+ IsAdminFilter()
+)
+async def handle_clusters_manage(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminServerEditorCallback,
+):
+ cluster_name = callback_data.data
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
- builder = InlineKeyboardBuilder()
-
- for server in cluster_servers:
- builder.row(
- InlineKeyboardButton(
- text=f"🌍 {server['server_name']}",
- callback_data=f"manage_server|{server['server_name']}",
- )
- )
-
- builder.row(
- InlineKeyboardButton(
- text="➕ Добавить сервер", callback_data=f"add_server|{cluster_name}"
- )
- )
-
- builder.row(
- InlineKeyboardButton(
- text="🌐 Доступность серверов",
- callback_data=f"server_availability|{cluster_name}",
- )
- )
-
- builder.row(
- InlineKeyboardButton(
- text="💾 Создать бэкап кластера",
- callback_data=f"backup_cluster|{cluster_name}",
- )
- )
-
- builder.row(
- InlineKeyboardButton(
- text="🔄 Синхронизировать",
- callback_data=f"sync_cluster|{cluster_name}",
- )
- )
-
- builder.row(
- InlineKeyboardButton(
- text="🔙 Назад в управление кластерами", callback_data="servers_editor"
- )
- )
-
- await callback_query.message.answer(
- f"🔧 Управление серверами для кластера {cluster_name}",
- reply_markup=builder.as_markup(),
+ await callback_query.message.edit_text(
+ text=f"🔧 Управление серверами для кластера {cluster_name}",
+ reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
-@router.callback_query(F.data.startswith("sync_cluster|"), IsAdminFilter())
-async def sync_cluster_handler(callback_query: types.CallbackQuery):
- """Обработчик для синхронизации ключей на всех серверах выбранного кластера."""
- cluster_name = callback_query.data.split("|")[1]
-
- conn = await asyncpg.connect(DATABASE_URL)
- try:
- query_keys = """
- SELECT tg_id, client_id, email, expiry_time
- FROM keys
- WHERE server_id = $1
- """
- keys_to_sync = await conn.fetch(query_keys, cluster_name)
-
- if not keys_to_sync:
- await callback_query.message.answer(
- f"❌ Нет ключей для синхронизации в кластере {cluster_name}.",
- reply_markup=InlineKeyboardBuilder()
- .row(
- InlineKeyboardButton(
- text="🔙 Назад", callback_data="servers_editor"
- )
- )
- .as_markup(),
- )
- return
-
- servers = await get_servers_from_db()
- cluster_servers = servers.get(cluster_name, [])
-
- for key in keys_to_sync:
- for server_info in cluster_servers:
- try:
- await create_key_on_cluster(
- cluster_name,
- key["tg_id"],
- key["client_id"],
- key["email"],
- key["expiry_time"],
- )
- await asyncio.sleep(0.6)
- except Exception as e:
- logger.error(f"Ошибка при добавлении ключа {key['client_id']} в кластер {cluster_name}: {e}")
-
- await callback_query.message.answer(
- f"✅ Ключи успешно синхронизированы для кластера {cluster_name}.",
- reply_markup=InlineKeyboardBuilder()
- .row(InlineKeyboardButton(text="🔙 Назад", callback_data="servers_editor"))
- .as_markup(),
- )
- except Exception as e:
- logger.error(f"Ошибка синхронизации ключей в кластере {cluster_name}: {e}")
- await callback_query.message.answer(
- f"❌ Произошла ошибка при синхронизации: {e}",
- reply_markup=InlineKeyboardBuilder()
- .row(InlineKeyboardButton(text="🔙 Назад", callback_data="servers_editor"))
- .as_markup(),
- )
- finally:
- await conn.close()
-
-
-@router.callback_query(F.data.startswith("server_availability|"), IsAdminFilter())
-async def handle_check_server_availability(callback_query: types.CallbackQuery):
- cluster_name = callback_query.data.split("|")[1]
+@router.callback_query(
+ AdminServerEditorCallback.filter(F.action == "servers_availability"),
+ IsAdminFilter()
+)
+async def handle_servers_availability(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminServerEditorCallback
+):
+ cluster_name = callback_data.data
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
if not cluster_servers:
- await callback_query.answer(f"Кластер '{cluster_name}' не содержит серверов.")
+ await callback_query.message.answer(
+ text=f"Кластер '{cluster_name}' не содержит серверов."
+ )
return
- in_progress_message = await callback_query.message.answer(
+ text = (
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
)
- availability_message = (
+ in_progress_message = await callback_query.message.answer(
+ text=text
+ )
+
+ text = (
f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n"
)
@@ -442,97 +321,83 @@ async def handle_check_server_availability(callback_query: types.CallbackQuery):
await xui.login()
online_users = len(await xui.client.online())
- availability_message += (
+ text += (
f"🌍 {server['server_name']}: {online_users} активных пользователей.\n"
)
except Exception as e:
- availability_message += f"❌ {server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n"
-
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}"
- )
- )
+ text += f"❌ {server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n"
await in_progress_message.edit_text(
- availability_message, reply_markup=builder.as_markup()
+ text=text,
+ reply_markup=build_admin_back_kb("servers")
)
- await callback_query.answer()
-
-
-@router.callback_query(F.data.startswith("manage_server|"), IsAdminFilter())
-async def handle_manage_server(callback_query: types.CallbackQuery, state: FSMContext):
- server_name = callback_query.data.split("|")[1]
+@router.callback_query(
+ AdminServerEditorCallback.filter(F.action == "servers_manage"),
+ IsAdminFilter()
+)
+async def handle_servers_manage(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminServerEditorCallback
+):
+ server_name = callback_data.data
servers = await get_servers_from_db()
- server = None
- cluster_name = None
- for cluster, cluster_servers in servers.items():
- server = next(
- (s for s in cluster_servers if s["server_name"] == server_name), None
- )
- if server:
- cluster_name = cluster
- break
+ cluster_name, server = next(
+ ((c, s) for c, cs in servers.items()
+ for s in cs if s["server_name"] == server_name),
+ (None, None)
+ )
if server:
api_url = server["api_url"]
subscription_url = server["subscription_url"]
inbound_id = server["inbound_id"]
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="🗑️ Удалить", callback_data=f"delete_server|{server_name}"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}"
- )
- )
-
- await callback_query.message.answer(
+ text = (
f"🔧 Информация о сервере {server_name}:\n\n"
f"📡 API URL: {api_url}\n"
f"🌐 Subscription URL: {subscription_url}\n"
- f"🔑 Inbound ID: {inbound_id}",
- parse_mode="HTML",
- reply_markup=builder.as_markup(),
+ f"🔑 Inbound ID: {inbound_id}"
+ )
+
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=build_manage_server_kb(server_name, cluster_name),
)
else:
- await callback_query.message.answer("❌ Сервер не найден.")
+ await callback_query.message.edit_text(
+ text="❌ Сервер не найден."
+ )
-@router.callback_query(F.data.startswith("delete_server|"), IsAdminFilter())
-async def handle_delete_server(callback_query: types.CallbackQuery, state: FSMContext):
- server_name = callback_query.data.split("|")[1]
-
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="✅ Да", callback_data=f"confirm_delete_server|{server_name}"
- ),
- InlineKeyboardButton(
- text="❌ Нет", callback_data=f"manage_server|{server_name}"
- ),
- )
-
- await callback_query.message.answer(
- f"🗑️ Вы уверены, что хотите удалить сервер {server_name}?",
- reply_markup=builder.as_markup(),
- )
-
-
-@router.callback_query(F.data.startswith("confirm_delete_server|"), IsAdminFilter())
-async def handle_confirm_delete_server(
- callback_query: types.CallbackQuery, state: FSMContext
+@router.callback_query(
+ AdminServerEditorCallback.filter(F.action == "servers_delete"),
+ IsAdminFilter()
+)
+async def handle_servers_delete(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminServerEditorCallback
):
- server_name = callback_query.data.split("|")[1]
+ server_name = callback_data.data
+
+ await callback_query.message.edit_text(
+ text=f"🗑️ Вы уверены, что хотите удалить сервер {server_name}?",
+ reply_markup=build_delete_server_kb(server_name),
+ )
+
+
+@router.callback_query(
+ AdminServerEditorCallback.filter(F.action == "servers_delete_confirm"),
+ IsAdminFilter()
+)
+async def handle_servers_delete_confirm(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminServerEditorCallback
+):
+ server_name = callback_data.data
conn = await asyncpg.connect(DATABASE_URL)
await conn.execute(
@@ -543,43 +408,48 @@ async def handle_confirm_delete_server(
)
await conn.close()
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="🔙 Назад в управление кластерами", callback_data="servers_editor"
- )
- )
-
- await callback_query.message.answer(
- f"🗑️ Сервер {server_name} успешно удален.", reply_markup=builder.as_markup()
+ await callback_query.message.edit_text(
+ text=f"🗑️ Сервер {server_name} успешно удален.",
+ reply_markup=build_admin_back_kb("servers")
)
-@router.callback_query(F.data.startswith("add_server|"), IsAdminFilter())
-async def handle_add_server(callback_query: types.CallbackQuery, state: FSMContext):
- cluster_name = callback_query.data.split("|")[1]
+@router.callback_query(
+ AdminServerEditorCallback.filter(F.action == "servers_add"),
+ IsAdminFilter()
+)
+async def handle_servers_add(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminServerEditorCallback,
+ state: FSMContext
+):
+ cluster_name = callback_data.data
await state.update_data(cluster_name=cluster_name)
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
- )
-
- await callback_query.message.answer(
+ text = (
f"Введите имя сервера для кластера {cluster_name}:\n\n"
"Рекомендуется указать локацию сервера в имени.\n\n"
- "Пример: server-asia, server-europe",
- parse_mode="HTML",
- reply_markup=builder.as_markup(),
+ "Пример: server-asia, server-europe"
)
- await state.set_state(UserEditorState.waiting_for_server_name)
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=build_admin_back_kb("servers"),
+ )
+
+ await state.set_state(AdminServersEditor.waiting_for_server_name)
-@router.callback_query(F.data.startswith("backup_cluster|"), IsAdminFilter())
-async def handle_backup_cluster(callback_query: types.CallbackQuery):
- cluster_name = callback_query.data.split("|")[1]
+@router.callback_query(
+ AdminServerEditorCallback.filter(F.action == "clusters_backup"),
+ IsAdminFilter()
+)
+async def handle_clusters_backup(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminServerEditorCallback,
+):
+ cluster_name = callback_data.data
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
@@ -592,13 +462,12 @@ async def handle_backup_cluster(callback_query: types.CallbackQuery):
)
await create_backup_and_send_to_admins(xui)
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="servers_editor"))
-
- await callback_query.message.answer(
+ text = (
f"Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!\n\n"
- f"🔔 Бэкапы отправлены в боты панелей.",
- parse_mode="HTML",
- reply_markup=builder.as_markup(),
+ f"🔔 Бэкапы отправлены в боты панелей."
+ )
+
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=build_admin_back_kb("servers"),
)
- await callback_query.answer()
diff --git a/handlers/admin/admin_stats.py b/handlers/admin/admin_stats.py
new file mode 100644
index 00000000..0a74d69f
--- /dev/null
+++ b/handlers/admin/admin_stats.py
@@ -0,0 +1,138 @@
+from datetime import datetime
+from typing import Any
+
+from aiogram import F, Router
+from aiogram.types import CallbackQuery
+
+from filters.admin import IsAdminFilter
+from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
+from keyboards.admin.stats_kb import build_stats_kb
+from logger import logger
+from utils.csv_export import export_payments_csv, export_users_csv
+
+router = Router()
+
+
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "stats"),
+ IsAdminFilter(),
+)
+async def handle_stats(
+ callback_query: CallbackQuery,
+ session: Any
+):
+ try:
+ total_users = await session.fetchval("SELECT COUNT(*) FROM users")
+ total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
+ total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
+
+ total_payments_today = await session.fetchval(
+ "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE"
+ )
+ total_payments_week = await session.fetchval(
+ "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
+ )
+ total_payments_month = await session.fetchval(
+ "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)"
+ )
+ total_payments_all_time = await session.fetchval(
+ "SELECT COALESCE(SUM(amount), 0) FROM payments"
+ )
+
+ registrations_today = await session.fetchval(
+ "SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE"
+ )
+ registrations_week = await session.fetchval(
+ "SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('week', CURRENT_DATE)"
+ )
+ registrations_month = await session.fetchval(
+ "SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('month', CURRENT_DATE)"
+ )
+
+ users_updated_today = await session.fetchval(
+ "SELECT COUNT(*) FROM users WHERE updated_at >= CURRENT_DATE"
+ )
+
+ active_keys = await session.fetchval(
+ "SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
+ int(datetime.utcnow().timestamp() * 1000),
+ )
+ expired_keys = total_keys - active_keys
+
+ stats_message = (
+ f"📊 Подробная статистика проекта:\n\n"
+ f"👥 Пользователи:\n"
+ f" 📅 За день: {registrations_today}\n"
+ f" 📆 За неделю: {registrations_week}\n"
+ f" 📆 За месяц: {registrations_month}\n"
+ f" 🌐 За все время: {total_users}\n\n"
+ f"🌟 Активные пользователи:\n"
+ f" 🌟 Активных сегодня: {users_updated_today}\n\n"
+ f"👥 Рефералы:\n"
+ f" 🤝 Всего привлечено: {total_referrals}\n\n"
+ f"🔑 Ключи:\n"
+ f" 🌈 Всего сгенерировано: {total_keys}\n"
+ f" ✅ Действующих: {active_keys}\n"
+ f" ❌ Просроченных: {expired_keys}\n\n"
+ f"💰 Финансовая статистика:\n"
+ f" 📅 За день: {total_payments_today} ₽\n"
+ f" 📆 За неделю: {total_payments_week} ₽\n"
+ f" 📆 За месяц: {total_payments_month} ₽\n"
+ f" 🏦 За все время: {total_payments_all_time} ₽\n"
+ )
+
+ await callback_query.message.edit_text(
+ text=stats_message,
+ reply_markup=build_stats_kb()
+ )
+ except Exception as e:
+ logger.error(f"Error in user_stats_menu: {e}")
+
+
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "stats_export_users_csv"),
+ IsAdminFilter(),
+)
+async def handle_export_users_csv(
+ callback_query: CallbackQuery,
+ session: Any
+):
+ kb = build_admin_back_kb("stats")
+
+ try:
+ export = await export_users_csv(session)
+ await callback_query.message.answer_document(
+ document=export,
+ caption="📥 Экспорт пользователей в CSV"
+ )
+ except Exception as e:
+ logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
+ await callback_query.message.edit_text(
+ text=f"❗ Произошла ошибка при экспорте: {e}",
+ reply_markup=kb
+ )
+
+
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "stats_export_payments_csv"),
+ IsAdminFilter(),
+)
+async def handle_export_payments_csv(
+ callback_query: CallbackQuery,
+ session: Any
+):
+ kb = build_admin_back_kb("stats")
+
+ try:
+ export = await export_payments_csv(session)
+ await callback_query.message.answer_document(
+ document=export,
+ caption="📥 Экспорт платежей в CSV"
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
+ await callback_query.message.edit_text(
+ text=f"❗ Произошла ошибка при экспорте: {e}",
+ reply_markup=kb
+ )
diff --git a/handlers/admin/admin_user_editor.py b/handlers/admin/admin_user_editor.py
deleted file mode 100644
index db2be7ba..00000000
--- a/handlers/admin/admin_user_editor.py
+++ /dev/null
@@ -1,766 +0,0 @@
-import asyncio
-from datetime import datetime
-from typing import Any
-
-import pytz
-from aiogram import Bot, F, Router, types
-from aiogram.fsm.context import FSMContext
-from aiogram.fsm.state import State, StatesGroup
-from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
-from aiogram.utils.keyboard import InlineKeyboardBuilder
-
-from config import TOTAL_GB
-from database import (
- delete_user_data,
- get_client_id_by_email,
- get_servers_from_db,
- restore_trial,
- update_key_expiry,
-)
-from filters.admin import IsAdminFilter
-from handlers.keys.key_utils import (
- delete_key_from_cluster,
- delete_key_from_db,
- renew_key_in_cluster,
-)
-from handlers.utils import sanitize_key_name
-from logger import logger
-
-router = Router()
-
-
-class UserEditorState(StatesGroup):
- waiting_for_tg_id = State()
- waiting_for_username = State()
- displaying_user_info = State()
- waiting_for_new_balance = State()
- waiting_for_key_name = State()
- waiting_for_expiry_time = State()
- waiting_for_message_text = State()
-
-
-@router.callback_query(F.data == "search_by_tg_id", IsAdminFilter())
-async def prompt_tg_id(callback_query: CallbackQuery, state: FSMContext):
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
- await callback_query.message.answer(
- "🔍 Введите Telegram ID клиента:", reply_markup=builder.as_markup()
- )
- await state.set_state(UserEditorState.waiting_for_tg_id)
-
-
-@router.callback_query(F.data == "search_by_username", IsAdminFilter())
-async def prompt_username(callback_query: CallbackQuery, state: FSMContext):
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
- await callback_query.message.answer(
- "🔍 Введите Username клиента:", reply_markup=builder.as_markup()
- )
- await state.set_state(UserEditorState.waiting_for_username)
-
-
-@router.message(UserEditorState.waiting_for_username, IsAdminFilter())
-async def handle_username_input(
- message: types.Message, state: FSMContext, session: Any
-):
- username = message.text.strip().lstrip("@").replace("https://t.me/", "")
- user_record = await session.fetchrow(
- "SELECT tg_id FROM users WHERE username = $1", username
- )
-
- if not user_record:
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
- await message.answer(
- "🔍 Пользователь с указанным username не найден. 🚫",
- reply_markup=builder.as_markup(),
- )
- await state.clear()
- return
-
- tg_id = user_record["tg_id"]
- username = await session.fetchval(
- "SELECT username FROM users WHERE tg_id = $1", tg_id
- )
- balance = await session.fetchval(
- "SELECT balance FROM connections WHERE tg_id = $1", tg_id
- )
- key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
- referral_count = await session.fetchval(
- "SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
- )
-
- if balance is None:
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
- await message.answer(
- "🚫 Пользователь с указанным tg_id не найден. 🔍",
- reply_markup=builder.as_markup(),
- )
- await state.clear()
- return
-
- builder = InlineKeyboardBuilder()
-
- for (email,) in key_records:
- builder.row(
- InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
- )
-
- builder.row(
- InlineKeyboardButton(
- text="📝 Изменить баланс",
- callback_data=f"change_balance_{tg_id}",
- )
- )
-
- builder.row(
- InlineKeyboardButton(
- text="🔄 Восстановить пробник",
- callback_data=f"restore_trial_{tg_id}",
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="🔄 Обновить клиента", callback_data=f"user_info|{tg_id}"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="✉️ Отправить сообщение", callback_data=f"send_message_{tg_id}"
- )
- )
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
-
- user_info = (
- f"📊 Информация о пользователе:\n\n"
- f"🆔 ID пользователя: {tg_id}\n"
- f"👤 Логин пользователя: @{username}\n"
- f"💰 Баланс: {balance}\n"
- f"👥 Количество рефералов: {referral_count}\n"
- f"🔑 Ключи (для редактирования нажмите на ключ):"
- )
- await message.answer(user_info, reply_markup=builder.as_markup())
- await state.set_state(UserEditorState.displaying_user_info)
-
-
-@router.callback_query(F.data.startswith("send_message_"))
-async def handle_send_message(callback_query: types.CallbackQuery, state: FSMContext):
- tg_id = callback_query.data.split("_")[2]
- await state.update_data(target_tg_id=tg_id)
- await callback_query.message.answer(
- "✉️ Введите текст сообщения, которое вы хотите отправить пользователю."
- )
- await state.set_state(UserEditorState.waiting_for_message_text)
-
-
-@router.message(UserEditorState.waiting_for_message_text, IsAdminFilter())
-async def process_send_message(message: types.Message, state: FSMContext, bot: Bot):
- data = await state.get_data()
- target_tg_id = data.get("target_tg_id")
-
- if not target_tg_id:
- await message.answer("🚫 Ошибка: ID пользователя не найден.")
- await state.clear()
- return
-
- try:
- await bot.send_message(chat_id=target_tg_id, text=message.text)
- await message.answer("✅ Сообщение успешно отправлено.")
- except Exception as e:
- await message.answer(f"❌ Не удалось отправить сообщение: {e}")
-
- await state.clear()
-
-
-@router.message(UserEditorState.waiting_for_tg_id, F.text.isdigit(), IsAdminFilter())
-async def handle_tg_id_input(message: types.Message, state: FSMContext, session: Any):
- tg_id = int(message.text)
- username = await session.fetchval(
- "SELECT username FROM users WHERE tg_id = $1", tg_id
- )
- balance = await session.fetchval(
- "SELECT balance FROM connections WHERE tg_id = $1", tg_id
- )
- key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
- referral_count = await session.fetchval(
- "SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
- )
-
- if balance is None:
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
- await message.answer(
- "❌ Пользователь с указанным tg_id не найден. 🔍",
- reply_markup=builder.as_markup(),
- )
- await state.clear()
- return
-
- builder = InlineKeyboardBuilder()
-
- for (email,) in key_records:
- builder.row(
- InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
- )
-
- builder.row(
- InlineKeyboardButton(
- text="📝 Изменить баланс",
- callback_data=f"change_balance_{tg_id}",
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="🔄 Обновить клиента", callback_data=f"user_info|{tg_id}"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="🔄 Восстановить пробник",
- callback_data=f"restore_trial_{tg_id}",
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="✉️ Отправить сообщение", callback_data=f"send_message_{tg_id}"
- )
- )
-
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
-
- user_info = (
- f"📊 Информация о пользователе:\n\n"
- f"🆔 ID пользователя: {tg_id}\n"
- f"👤 Логин пользователя: @{username}\n"
- f"💰 Баланс: {balance}\n"
- f"👥 Количество рефералов: {referral_count}\n"
- f"🔑 Ключи (для редактирования нажмите на ключ):"
- )
- await message.answer(user_info, reply_markup=builder.as_markup())
- await state.set_state(UserEditorState.displaying_user_info)
-
-
-@router.callback_query(F.data.startswith("restore_trial_"), IsAdminFilter())
-async def handle_restore_trial(callback_query: types.CallbackQuery, session: Any):
- tg_id = int(callback_query.data.split("_")[2])
-
- await restore_trial(tg_id, session)
-
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="🔙 Назад в меню администратора", callback_data="admin"
- )
- )
-
- await callback_query.message.answer(
- "✅ Триал успешно восстановлен.", reply_markup=builder.as_markup()
- )
-
-
-@router.callback_query(F.data.startswith("change_balance_"), IsAdminFilter())
-async def process_balance_change(callback_query: CallbackQuery, state: FSMContext):
- tg_id = int(callback_query.data.split("_")[2])
- await state.update_data(tg_id=tg_id)
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
- await callback_query.message.answer(
- "💸 Введите новую сумму баланса:", reply_markup=builder.as_markup()
- )
- await state.set_state(UserEditorState.waiting_for_new_balance)
-
-
-@router.message(UserEditorState.waiting_for_new_balance, IsAdminFilter())
-async def handle_new_balance_input(
- message: types.Message, state: FSMContext, session: Any
-):
- if not message.text.isdigit() or int(message.text) < 0:
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
- await message.answer(
- "❌ Пожалуйста, введите корректную сумму для изменения баланса.",
- reply_markup=builder.as_markup(),
- )
- return
-
- new_balance = int(message.text)
- user_data = await state.get_data()
- tg_id = user_data.get("tg_id")
-
- await session.execute(
- "UPDATE connections SET balance = $1 WHERE tg_id = $2",
- new_balance,
- tg_id,
- )
-
- response_message = f"✅ Баланс успешно изменен на {new_balance}."
-
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="🔙 Назад в меню администратора",
- callback_data="admin",
- )
- )
- await message.answer(response_message, reply_markup=builder.as_markup())
- await state.clear()
-
-
-async def get_key_details(email, session):
-
- record = await session.fetchrow(
- """
- SELECT k.key, k.expiry_time, k.server_id, c.tg_id, c.balance
- FROM keys k
- JOIN connections c ON k.tg_id = c.tg_id
- WHERE k.email = $1
- """,
- email,
- )
-
- if not record:
- return None
-
- cluster_name = record["server_id"]
-
- moscow_tz = pytz.timezone("Europe/Moscow")
- expiry_date = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=moscow_tz)
- current_date = datetime.now(moscow_tz)
- time_left = expiry_date - current_date
-
- if time_left.total_seconds() <= 0:
- days_left_message = "Ключ истек."
- elif time_left.days > 0:
- days_left_message = f"Осталось дней: {time_left.days}"
- else:
- hours_left = time_left.seconds // 3600
- days_left_message = f"Осталось часов: {hours_left}"
-
- return {
- "key": record["key"],
- "expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"),
- "days_left_message": days_left_message,
- "server_name": cluster_name,
- "balance": record["balance"],
- "tg_id": record["tg_id"],
- }
-
-
-@router.callback_query(F.data.startswith("edit_key_"), IsAdminFilter())
-async def process_key_edit(callback_query: CallbackQuery, session: Any):
- email = callback_query.data.split("_", 2)[2]
- key_details = await get_key_details(email, session)
-
- if not key_details:
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
- await callback_query.message.answer(
- "🔍 Информация о ключе не найдена. 🚫",
- reply_markup=builder.as_markup(),
- )
- return
-
- response_message = (
- f"🔑 Ключ: {key_details['key']}\n"
- f"⏰ Дата истечения: {key_details['expiry_date']}\n"
- f"💰 Баланс пользователя: {key_details['balance']}\n"
- f"🌐 Кластер: {key_details['server_name']}"
- )
-
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(
- text="ℹ️ Получить информацию о юзере",
- callback_data=f"user_info|{key_details['tg_id']}",
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="⏳ Изменить время истечения",
- callback_data=f"change_expiry|{email}",
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="❌ Удалить ключ",
- callback_data=f"delete_key_admin|{email}",
- )
- )
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
-
- await callback_query.message.answer(
- response_message, reply_markup=builder.as_markup()
- )
-
-
-@router.callback_query(F.data == "search_by_key_name", IsAdminFilter())
-async def prompt_key_name(callback_query: CallbackQuery, state: FSMContext):
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
- await callback_query.message.answer(
- "🔑 Введите имя ключа:", reply_markup=builder.as_markup()
- )
- await state.set_state(UserEditorState.waiting_for_key_name)
-
-
-@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
-async def handle_key_name_input(
- message: types.Message, state: FSMContext, session: Any
-):
- key_name = sanitize_key_name(message.text)
- key_details = await get_key_details(key_name, session)
-
- if not key_details:
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
- await message.answer(
- "🚫 Пользователь с указанным именем ключа не найден.",
- reply_markup=builder.as_markup(),
- )
- await state.clear()
- return
-
- response_message = (
- f"🔑 Ключ: {key_details['key']}\n"
- f"⏰ Дата истечения: {key_details['expiry_date']}\n"
- f"💰 Баланс пользователя: {key_details['balance']}\n"
- f"🌐 Сервер: {key_details['server_name']}"
- )
-
- key_buttons = InlineKeyboardBuilder()
- key_buttons.row(
- InlineKeyboardButton(
- text="ℹ️ Получить информацию о юзере",
- callback_data=f"user_info|{key_details['tg_id']}",
- )
- )
- key_buttons.row(
- InlineKeyboardButton(
- text="⏳ Изменить время истечения",
- callback_data=f"change_expiry|{key_name}",
- )
- )
- key_buttons.row(
- InlineKeyboardButton(
- text="❌ Удалить ключ",
- callback_data=f"delete_key_admin|{key_name}",
- )
- )
- key_buttons.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
-
- await message.answer(response_message, reply_markup=key_buttons.as_markup())
- await state.clear()
-
-
-@router.callback_query(F.data.startswith("change_expiry|"), IsAdminFilter())
-async def prompt_expiry_change(callback_query: CallbackQuery, state: FSMContext):
- email = callback_query.data.split("|")[1]
- await callback_query.message.answer(
- f"⏳ Введите новое время истечения для ключа {email} в формате YYYY-MM-DD HH:MM:SS:"
- )
- await state.update_data(email=email)
- await state.set_state(UserEditorState.waiting_for_expiry_time)
-
-
-@router.message(UserEditorState.waiting_for_expiry_time, IsAdminFilter())
-async def handle_expiry_time_input(
- message: types.Message, state: FSMContext, session: Any
-):
- user_data = await state.get_data()
- email = user_data.get("email")
-
- if not email:
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
- await message.answer(
- "📧 Email не найден в состоянии. 🚫", reply_markup=builder.as_markup()
- )
- await state.clear()
- return
-
- try:
- expiry_time_str = message.text
- moscow_tz = pytz.timezone("Europe/Moscow")
- expiry_time = int(
- datetime.strptime(expiry_time_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=moscow_tz).timestamp() * 1000
- )
-
- client_id = await get_client_id_by_email(email)
- if client_id is None:
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
- )
- await message.answer(
- f"🚫 Клиент с email {email} не найден. 🔍",
- reply_markup=builder.as_markup(),
- )
- await state.clear()
- return
-
- record = await session.fetchrow(
- "SELECT server_id FROM keys WHERE client_id = $1", client_id
- )
- if not record:
- builder = InlineKeyboardBuilder()
- builder.row(
- InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
- )
- await message.answer(
- "🚫 Клиент не найден в базе данных. 🔍",
- reply_markup=builder.as_markup(),
- )
- await state.clear()
- return
-
- clusters = await get_servers_from_db()
-
- async def update_key_on_all_servers():
- tasks = []
- for cluster_name, cluster_servers in clusters.items():
- for server in cluster_servers:
- tasks.append(
- asyncio.create_task(
- renew_key_in_cluster(
- cluster_name,
- email,
- client_id,
- expiry_time,
- total_gb=TOTAL_GB,
- )
- )
- )
- await asyncio.gather(*tasks)
-
- await update_key_on_all_servers()
-
- await update_key_expiry(client_id, expiry_time)
-
- response_message = f"✅ Время истечения ключа для клиента {client_id} ({email}) успешно обновлено на всех серверах."
-
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
- await message.answer(response_message, reply_markup=builder.as_markup())
- except ValueError:
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
- await message.answer(
- "❌ Пожалуйста, используйте формат: YYYY-MM-DD HH:MM:SS.",
- reply_markup=builder.as_markup(),
- )
- except Exception as e:
- logger.error(e)
- await state.clear()
-
-
-@router.callback_query(F.data.startswith("delete_key_admin|"), IsAdminFilter())
-async def process_callback_delete_key(
- callback_query: types.CallbackQuery, session: Any
-):
- email = callback_query.data.split("|")[1]
- client_id = await session.fetchval(
- "SELECT client_id FROM keys WHERE email = $1", email
- )
-
- if client_id is None:
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
- await callback_query.message.answer(
- "🔍 Ключ не найден. 🚫", reply_markup=builder.as_markup()
- )
- return
-
- builder = InlineKeyboardBuilder()
- builder.row(
- types.InlineKeyboardButton(
- text="✅ Да, удалить",
- callback_data=f"confirm_delete_admin|{client_id}",
- )
- )
- builder.row(
- types.InlineKeyboardButton(text="❌ Нет, отменить", callback_data="user_editor")
- )
- await callback_query.message.answer(
- "❓ Вы уверены, что хотите удалить ключ?",
- reply_markup=builder.as_markup(),
- )
-
-
-@router.callback_query(F.data.startswith("confirm_delete_admin|"), IsAdminFilter())
-async def process_callback_confirm_delete(
- callback_query: types.CallbackQuery, session: Any
-):
- client_id = callback_query.data.split("|")[1]
- record = await session.fetchrow(
- "SELECT email FROM keys WHERE client_id = $1", client_id
- )
-
- if record:
- email = record["email"]
- response_message = "✅ Ключ успешно удален."
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
-
- clusters = await get_servers_from_db()
-
- async def delete_key_from_servers(email, client_id):
- tasks = []
- for cluster_name, cluster_servers in clusters.items():
- for server in cluster_servers:
- tasks.append(
- delete_key_from_cluster(cluster_name, email, client_id)
- )
- await asyncio.gather(*tasks)
-
- await delete_key_from_servers(email, client_id)
- await delete_key_from_db(client_id, session)
-
- await callback_query.message.answer(
- response_message, reply_markup=builder.as_markup()
- )
- else:
- response_message = "🚫 Ключ не найден или уже удален."
- builder = InlineKeyboardBuilder()
- builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
- await callback_query.message.answer(
- response_message, reply_markup=builder.as_markup()
- )
-
-
-@router.callback_query(F.data.startswith("user_info|"), IsAdminFilter())
-async def handle_user_info(
- callback_query: types.CallbackQuery, state: FSMContext, session: Any
-):
- tg_id = int(callback_query.data.split("|")[1])
- username = await session.fetchval(
- "SELECT username FROM users WHERE tg_id = $1", tg_id
- )
- balance = await session.fetchval(
- "SELECT balance FROM connections WHERE tg_id = $1", tg_id
- )
- key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
- referral_count = await session.fetchval(
- "SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
- )
-
- builder = InlineKeyboardBuilder()
-
- for (email,) in key_records:
- builder.row(
- InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
- )
-
- builder.row(
- InlineKeyboardButton(
- text="📝 Изменить баланс", callback_data=f"change_balance_{tg_id}"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="🔄 Восстановить пробник", callback_data=f"restore_trial_{tg_id}"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="🔄 Обновить клиента", callback_data=f"user_info|{tg_id}"
- )
- )
- builder.row(
- InlineKeyboardButton(
- text="✉️ Отправить сообщение", callback_data=f"send_message_{tg_id}"
- )
- )
- builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
-
- user_info = (
- f"📊 Информация о пользователе:\n\n"
- f"🆔 ID пользователя: {tg_id}\n"
- f"👤 Логин пользователя: @{username}\n"
- f"💰 Баланс: {balance}\n"
- f"👥 Количество рефералов: {referral_count}\n"
- f"🔑 Ключи (для редактирования нажмите на ключ):"
- )
-
- await callback_query.message.answer(user_info, reply_markup=builder.as_markup())
- await state.set_state(UserEditorState.displaying_user_info)
-
-
-@router.callback_query(F.data.startswith("confirm_delete_user_"), IsAdminFilter())
-async def confirm_delete_user(
- callback_query: types.CallbackQuery, state: FSMContext, session: Any
-):
- tg_id = int(callback_query.data.split("_")[3])
-
- confirmation_markup = InlineKeyboardMarkup(
- row_width=2,
- inline_keyboard=[
- [
- InlineKeyboardButton(
- text="✅ Подтвердить", callback_data=f"delete_user_{tg_id}"
- )
- ],
- [InlineKeyboardButton(text="❌ Отменить", callback_data="user_editor")],
- ],
- )
-
- await callback_query.message.answer(
- f"Вы уверены, что хотите удалить пользователя с ID {tg_id}?",
- reply_markup=confirmation_markup,
- )
-
-
-@router.callback_query(F.data.startswith("delete_user_"), IsAdminFilter())
-async def delete_user(callback_query: types.CallbackQuery, session: Any):
- tg_id = int(callback_query.data.split("_")[2])
-
- key_records = await session.fetch(
- "SELECT email, client_id FROM keys WHERE tg_id = $1", tg_id
- )
-
- async def delete_keys_from_servers():
- try:
- tasks = []
- for email, client_id in key_records:
- servers = await get_servers_from_db()
- for cluster_id, cluster in servers.items():
- tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
- await asyncio.gather(*tasks)
- except Exception as e:
- logger.error(
- f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}"
- )
-
- await delete_keys_from_servers()
-
- try:
- await delete_user_data(session, tg_id)
-
- back_button = InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
- keyboard = InlineKeyboardMarkup(inline_keyboard=[[back_button]])
-
- await callback_query.message.answer(
- f"🗑️ Пользователь с ID {tg_id} был удален.", reply_markup=keyboard
- )
- except Exception as e:
- logger.error(
- f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}"
- )
- await callback_query.message.answer(
- f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова."
- )
diff --git a/handlers/admin/admin_users.py b/handlers/admin/admin_users.py
new file mode 100644
index 00000000..2520299a
--- /dev/null
+++ b/handlers/admin/admin_users.py
@@ -0,0 +1,886 @@
+import asyncio
+from datetime import datetime, timezone
+from typing import Any
+
+from aiogram import F, Router, types
+from aiogram.exceptions import TelegramBadRequest
+from aiogram.filters.callback_data import CallbackData
+from aiogram.fsm.context import FSMContext
+from aiogram.fsm.state import State, StatesGroup
+from aiogram.types import CallbackQuery
+
+from config import TOTAL_GB
+from database import delete_user_data, get_client_id_by_email, get_servers_from_db, restore_trial, update_key_expiry
+from filters.admin import IsAdminFilter
+from handlers.keys.key_utils import (
+ delete_key_from_cluster,
+ delete_key_from_db,
+ renew_key_in_cluster, update_subscription,
+)
+from handlers.utils import sanitize_key_name
+from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
+from keyboards.admin.users_kb import build_user_edit_kb, build_key_edit_kb, build_key_delete_kb, \
+ build_user_delete_kb, AdminUserEditorCallback, build_editor_kb, build_users_balance_kb, \
+ build_users_balance_change_kb, build_user_key_kb, build_users_key_expiry_kb, AdminUserKeyEditorCallback, \
+ build_users_key_show_kb
+from logger import logger
+
+router = Router()
+
+
+class UserEditorState(StatesGroup):
+ # search
+ waiting_for_user_data = State()
+ waiting_for_key_name = State()
+ # updating data
+ waiting_for_balance = State()
+ waiting_for_expiry_time = State()
+ waiting_for_message_text = State()
+
+
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "search_user"),
+ IsAdminFilter(),
+)
+async def handle_search_user(
+ callback_query: CallbackQuery,
+ state: FSMContext
+):
+ text = (
+ "🔍 Поиск пользователя"
+ "\n\n📌 Введите ID, Username или перешлите сообщение пользователя."
+ "\n\n🆔 ID - числовой айди"
+ "\n📝 Username - юзернейм пользователя"
+ "\n\n✉️ Для поиска, вы можете просто переслать сообщение от пользователя."
+ )
+
+ await state.set_state(UserEditorState.waiting_for_user_data)
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=build_admin_back_kb()
+ )
+
+
+@router.callback_query(
+ AdminPanelCallback.filter(F.action == "search_key"),
+ IsAdminFilter(),
+)
+async def handle_search_key(
+ callback_query: CallbackQuery,
+ state: FSMContext
+):
+ await state.set_state(UserEditorState.waiting_for_key_name)
+ await callback_query.message.edit_text(
+ text="🔑 Введите имя ключа для поиска:",
+ reply_markup=build_admin_back_kb()
+ )
+
+
+@router.message(
+ UserEditorState.waiting_for_user_data,
+ IsAdminFilter()
+)
+async def handle_user_data_input(
+ message: types.Message,
+ state: FSMContext,
+ session: Any
+):
+ kb = build_admin_back_kb()
+
+ if message.forward_from:
+ tg_id = message.forward_from.id
+ await process_user_search(message, state, session, tg_id)
+ return
+
+ if not message.text:
+ await message.answer(
+ text="🚫 Пожалуйста, отправьте текстовое сообщение.",
+ reply_markup=kb
+ )
+ return
+
+ if message.text.isdigit():
+ tg_id = int(message.text)
+ else:
+ # Удаление '@' символа в начале сообщения
+ username = message.text.strip().lstrip('@')
+ # Удаление начала ссылки на профиль
+ username = username.replace('https://t.me/', '')
+
+ user = await session.fetchrow(
+ "SELECT tg_id FROM users WHERE username = $1", username
+ )
+
+ if not user:
+ await message.answer(
+ text="🚫 Пользователь с указанным Username не найден!",
+ reply_markup=kb,
+ )
+ return
+
+ tg_id = user["tg_id"]
+
+ await process_user_search(message, state, session, tg_id)
+
+
+@router.message(
+ UserEditorState.waiting_for_key_name,
+ IsAdminFilter()
+)
+async def handle_key_name_input(
+ message: types.Message,
+ state: FSMContext,
+ session: Any
+):
+ kb = build_admin_back_kb()
+
+ if not message.text:
+ await message.answer(
+ text="🚫 Пожалуйста, отправьте текстовое сообщение.",
+ reply_markup=kb
+ )
+ return
+
+ key_name = sanitize_key_name(message.text)
+ key_details = await get_key_details(key_name, session)
+
+ if not key_details:
+ await message.answer(
+ text="🚫 Пользователь с указанным именем ключа не найден.",
+ reply_markup=kb
+ )
+ return
+
+ await process_user_search(message, state, session, key_details["tg_id"])
+
+
+@router.callback_query(
+ AdminUserEditorCallback.filter(F.action == "users_send_message"),
+ IsAdminFilter(),
+)
+async def handle_send_message(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminUserEditorCallback,
+ state: FSMContext
+):
+ tg_id = callback_data.tg_id
+
+ await callback_query.message.edit_text(
+ text="✉️ Введите текст сообщения, которое вы хотите отправить пользователю:",
+ reply_markup=build_editor_kb(tg_id)
+ )
+
+ await state.update_data(tg_id=tg_id)
+ await state.set_state(UserEditorState.waiting_for_message_text)
+
+
+@router.message(
+ UserEditorState.waiting_for_message_text,
+ IsAdminFilter()
+)
+async def handle_message_text_input(
+ message: types.Message,
+ state: FSMContext
+):
+ data = await state.get_data()
+ tg_id = data.get("tg_id")
+
+ try:
+ await message.bot.send_message(
+ chat_id=tg_id,
+ text=message.text
+ )
+ await message.answer(
+ text="✅ Сообщение успешно отправлено.",
+ reply_markup=build_editor_kb(tg_id)
+ )
+ except Exception as e:
+ await message.answer(
+ text=f"❌ Не удалось отправить сообщение: {e}",
+ reply_markup=build_editor_kb(tg_id)
+ )
+
+ await state.clear()
+
+
+@router.callback_query(
+ AdminUserEditorCallback.filter(F.action == "users_trial_restore"),
+ IsAdminFilter(),
+)
+async def handle_trial_restore(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminUserEditorCallback,
+ session: Any
+):
+ tg_id = callback_data.tg_id
+
+ await restore_trial(tg_id, session)
+ await callback_query.message.edit_text(
+ text="✅ Триал успешно восстановлен!",
+ reply_markup=build_editor_kb(tg_id)
+ )
+
+
+@router.callback_query(
+ AdminUserEditorCallback.filter(F.action == "users_balance_edit"),
+ IsAdminFilter()
+)
+async def handle_balance_change(
+ callback_query: CallbackQuery,
+ callback_data: AdminUserEditorCallback,
+ session: Any
+):
+ tg_id = callback_data.tg_id
+
+ records = await session.fetch("""
+ SELECT amount, payment_system, status, created_at
+ FROM payments
+ WHERE tg_id = $1
+ ORDER BY created_at DESC
+ LIMIT 5
+ """, tg_id)
+
+ balance = await get_user_balance(tg_id, session)
+
+ text = (
+ f"💵 Изменение баланса"
+ f"\n\n🆔 ID: {tg_id}"
+ f"\n💰 Баланс: {balance}Р"
+ f"\n📊 Последние операции (5):"
+ )
+
+ if records:
+ for record in records:
+ amount = record["amount"]
+ payment_system = record["payment_system"]
+ status = record["status"]
+ date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
+ text += (
+ f"\n
💸 Сумма: {amount} | {payment_system}" + f"\n📌 Статус: {status}" + f"\n⏳ Дата: {date}" + ) + else: + text += "\n 🚫 Отсутствуют" + + await callback_query.message.edit_text( + text=text, + reply_markup=build_users_balance_kb(tg_id) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_balance_add"), + IsAdminFilter() +) +async def handle_balance_add( + callback_query: CallbackQuery, + callback_data: AdminUserEditorCallback, + state: FSMContext, + session: Any +): + tg_id = callback_data.tg_id + amount = callback_data.data + + if amount: + await add_user_balance(tg_id, int(amount), session) + await handle_balance_change(callback_query, callback_data, session) + return + + await state.update_data(tg_id=tg_id, op_type="add") + await state.set_state(UserEditorState.waiting_for_balance) + + await callback_query.message.edit_text( + text="✍️ Введите сумму, которую хотите добавить на баланс пользователя:", + reply_markup=build_users_balance_change_kb(tg_id) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_balance_take"), + IsAdminFilter() +) +async def handle_balance_take( + callback_query: CallbackQuery, + callback_data: AdminUserEditorCallback, + state: FSMContext +): + tg_id = callback_data.tg_id + + await state.update_data(tg_id=tg_id, op_type="take") + await state.set_state(UserEditorState.waiting_for_balance) + + await callback_query.message.edit_text( + text="✍️ Введите сумму, которую хотите вычесть из баланса пользователя:", + reply_markup=build_users_balance_change_kb(tg_id) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_balance_set"), + IsAdminFilter() +) +async def handle_balance_set( + callback_query: CallbackQuery, + callback_data: AdminUserEditorCallback, + state: FSMContext +): + tg_id = callback_data.tg_id + + await state.update_data(tg_id=tg_id, op_type="set") + await state.set_state(UserEditorState.waiting_for_balance) + + await callback_query.message.edit_text( + text="✍️ Введите баланс, который хотите установить пользователю:", + reply_markup=build_users_balance_change_kb(tg_id) + ) + + +@router.message( + UserEditorState.waiting_for_balance, + IsAdminFilter() +) +async def handle_balance_input( + message: types.Message, + state: FSMContext, + session: Any +): + data = await state.get_data() + tg_id = data.get("tg_id") + op_type = data.get("op_type") + + if not message.text.isdigit() or int(message.text) < 0: + await message.answer( + text="🚫 Пожалуйста, введите корректную сумму!", + reply_markup=build_users_balance_change_kb(tg_id) + ) + return + + amount = int(message.text) + + if op_type == "add": + text = f"✅ К балансу пользователя добавлено {amount}Р" + await add_user_balance(tg_id, amount, session) + elif op_type == "take": + text = f"✅ Из баланса пользователя было вычтено {amount}Р" + await add_user_balance(tg_id, -amount, session) + else: + text = f"✅ Баланс пользователя изменен на {amount}Р" + await set_user_balance(tg_id, amount, session) + + await message.answer( + text=text, + reply_markup=build_users_balance_change_kb(tg_id) + ) + + +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_key_edit"), + IsAdminFilter() +) +async def handle_key_edit( + callback_query: CallbackQuery, + callback_data: CallbackData, + session: Any, + update: bool = False +): + email = callback_data.data + key_details = await get_key_details(email, session) + + if not key_details: + await callback_query.message.edit_text( + text="🚫 Информация о ключе не найдена.", + reply_markup=build_editor_kb(callback_data.tg_id), + ) + return + + text = ( + f"🔑 Информация о ключе" + f"\n\n
{key_details['key']}"
+ f"\n\n⏰ Дата истечения: {key_details['expiry_date']} (UTC)"
+ f"\n🌐 Кластер: {key_details['cluster_name']}"
+ f"\n🆔 ID клиента: {key_details['tg_id']}"
+ )
+
+ if not update or not callback_data.edit:
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=build_key_edit_kb(key_details, email)
+ )
+ else:
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=build_users_key_expiry_kb(callback_data.tg_id, email)
+ )
+
+
+@router.callback_query(
+ AdminUserEditorCallback.filter(F.action == "users_expiry_edit"),
+ IsAdminFilter()
+)
+async def handle_change_expiry(
+ callback_query: CallbackQuery,
+ callback_data: AdminUserEditorCallback
+):
+ tg_id = callback_data.tg_id
+ email = callback_data.data
+
+ await callback_query.message.edit_reply_markup(
+ reply_markup=build_users_key_expiry_kb(tg_id, email)
+ )
+
+
+@router.callback_query(
+ AdminUserKeyEditorCallback.filter(F.action == "add"),
+ IsAdminFilter()
+)
+async def handle_expiry_add(
+ callback_query: CallbackQuery,
+ callback_data: AdminUserKeyEditorCallback,
+ state: FSMContext,
+ session: Any
+):
+ tg_id = callback_data.tg_id
+ email = callback_data.data
+ month = callback_data.month
+
+ key_details = await get_key_details(email, session)
+
+ if not key_details:
+ await callback_query.message.edit_text(
+ text="🚫 Информация о ключе не найдена.",
+ reply_markup=build_editor_kb(tg_id),
+ )
+ return
+
+ if month:
+ await change_expiry_time(key_details["expiry_time"] + month * 30 * 24 * 3600 * 1000, email, session)
+ await handle_key_edit(callback_query, callback_data, session, True)
+ return
+
+ await state.update_data(tg_id=tg_id, email=email, op_type="add")
+ await state.set_state(UserEditorState.waiting_for_expiry_time)
+
+ await callback_query.message.edit_text(
+ text="✍️ Введите количество дней, которое хотите добавить к времени действия ключа:",
+ reply_markup=build_users_key_show_kb(tg_id, email)
+ )
+
+
+@router.callback_query(
+ AdminUserKeyEditorCallback.filter(F.action == "take"),
+ IsAdminFilter()
+)
+async def handle_expiry_take(
+ callback_query: CallbackQuery,
+ callback_data: AdminUserKeyEditorCallback,
+ state: FSMContext
+):
+ tg_id = callback_data.tg_id
+ email = callback_data.data
+
+ await state.update_data(tg_id=tg_id, email=email, op_type="take")
+ await state.set_state(UserEditorState.waiting_for_expiry_time)
+
+ await callback_query.message.edit_text(
+ text="✍️ Введите количество дней, которое хотите вычесть из времени действия ключа:",
+ reply_markup=build_users_key_show_kb(tg_id, email)
+ )
+
+
+@router.callback_query(
+ AdminUserKeyEditorCallback.filter(F.action == "set"),
+ IsAdminFilter()
+)
+async def handle_expiry_set(
+ callback_query: CallbackQuery,
+ callback_data: AdminUserKeyEditorCallback,
+ state: FSMContext
+):
+ tg_id = callback_data.tg_id
+ email = callback_data.data
+
+ await state.update_data(tg_id=tg_id, email=email, op_type="set")
+ await state.set_state(UserEditorState.waiting_for_expiry_time)
+
+ text = (
+ "✍️ Введите новое время действия ключа:"
+ "\n\n📌 Формат: год-месяц-день час:минута"
+ "\n Пример: 2025-02-09 09:01"
+ )
+
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=build_users_key_show_kb(tg_id, email)
+ )
+
+
+@router.message(
+ UserEditorState.waiting_for_expiry_time,
+ IsAdminFilter()
+)
+async def handle_expiry_time_input(
+ message: types.Message,
+ state: FSMContext,
+ session: Any
+):
+ data = await state.get_data()
+ tg_id = data.get("tg_id")
+ email = data.get("email")
+ op_type = data.get("op_type")
+
+ if op_type != "set" and (not message.text.isdigit() or int(message.text) < 0):
+ await message.answer(
+ text="🚫 Пожалуйста, введите корректное количество дней!",
+ reply_markup=build_users_key_show_kb(tg_id, email)
+ )
+ return
+
+ key_details = await get_key_details(email, session)
+
+ if not key_details:
+ await message.answer(
+ text="🚫 Информация о ключе не найдена.",
+ reply_markup=build_editor_kb(tg_id),
+ )
+ return
+
+ if op_type == "add":
+ days = int(message.text)
+ text = f"✅ Ко времени действия ключа добавлено {days} дн."
+ await change_expiry_time(key_details["expiry_time"] + days * 24 * 3600 * 1000, email, session)
+ elif op_type == "take":
+ days = int(message.text)
+ text = f"✅ Из времени действия ключа вычтено {days} дн."
+ await change_expiry_time(key_details["expiry_time"] - days * 24 * 3600 * 1000, email, session)
+ else:
+ try:
+ expiry_time = int(
+ datetime.strptime(message.text, "%Y-%m-%d %H:%M").timestamp() * 1000
+ )
+ text = f"✅ Время действия ключа изменено на {message.text}"
+ await change_expiry_time(expiry_time, email, session)
+ except ValueError:
+ text = f"🚫 Пожалуйста, используйте корректный формат даты!"
+ except Exception as e:
+ text = f"❗ Произошла ошибка во время изменения времени действия ключа: {e}"
+
+ await message.answer(
+ text=text,
+ reply_markup=build_users_key_show_kb(tg_id, email)
+ )
+
+
+@router.callback_query(
+ AdminUserEditorCallback.filter(F.action == "users_update_key"),
+ IsAdminFilter()
+)
+async def handle_update_key(
+ callback_query: CallbackQuery,
+ callback_data: AdminUserEditorCallback,
+ session: Any
+):
+ tg_id = callback_data.tg_id
+ email = callback_data.data
+
+ try:
+ await update_subscription(tg_id, email, session)
+ await handle_key_edit(callback_query, callback_data, session)
+ except TelegramBadRequest:
+ pass
+ except Exception as e:
+ logger.error(f"Ошибка при обновлении ключа {email} администратором: {e}")
+ await callback_query.message.answer(
+ text=f"❗ Произошла ошибка при обновлении ключа: {e}",
+ reply_markup=build_user_key_kb(tg_id, email)
+ )
+
+
+@router.callback_query(
+ AdminUserEditorCallback.filter(F.action == "users_delete_key"),
+ IsAdminFilter()
+)
+async def handle_delete_key(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminUserEditorCallback,
+ session: Any
+):
+ email = callback_data.data
+ client_id = await session.fetchval(
+ "SELECT client_id FROM keys WHERE email = $1", email
+ )
+
+ if client_id is None:
+ await callback_query.message.edit_text(
+ text="🚫 Ключ не найден!",
+ reply_markup=build_editor_kb(callback_data.tg_id)
+ )
+ return
+
+ await callback_query.message.edit_text(
+ text="❓ Вы уверены, что хотите удалить ключ?",
+ reply_markup=build_key_delete_kb(callback_data.tg_id, email)
+ )
+
+
+@router.callback_query(
+ AdminUserEditorCallback.filter(F.action == "users_delete_key_confirm"),
+ IsAdminFilter()
+)
+async def handle_delete_key_confirm(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminUserEditorCallback,
+ session: Any
+):
+ email = callback_data.data
+ record = await session.fetchrow(
+ "SELECT client_id FROM keys WHERE email = $1", email
+ )
+
+ kb = build_editor_kb(callback_data.tg_id)
+
+ if record:
+ client_id = record["client_id"]
+ clusters = await get_servers_from_db()
+
+ async def delete_key_from_servers():
+ tasks = []
+ for cluster_name, cluster_servers in clusters.items():
+ for _ in cluster_servers:
+ tasks.append(
+ delete_key_from_cluster(cluster_name, email, client_id)
+ )
+ await asyncio.gather(*tasks)
+
+ await delete_key_from_servers()
+ await delete_key_from_db(client_id, session)
+
+ await callback_query.message.edit_text(
+ text="✅ Ключ успешно удален.",
+ reply_markup=kb
+ )
+ else:
+ await callback_query.message.edit_text(
+ text="🚫 Ключ не найден или уже удален.",
+ reply_markup=kb
+ )
+
+
+@router.callback_query(
+ AdminUserEditorCallback.filter(F.action == "users_delete_user"),
+ IsAdminFilter()
+)
+async def handle_delete_user(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminUserEditorCallback
+):
+ tg_id = callback_data.tg_id
+ await callback_query.message.edit_text(
+ text=f"❗️ Вы уверены, что хотите удалить пользователя с ID {tg_id}?",
+ reply_markup=build_user_delete_kb(tg_id)
+ )
+
+
+@router.callback_query(
+ AdminUserEditorCallback.filter(F.action == "users_delete_user_confirm"),
+ IsAdminFilter()
+)
+async def handle_delete_user_confirm(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminUserEditorCallback,
+ session: Any
+):
+ tg_id = callback_data.tg_id
+ key_records = await session.fetch("SELECT email, client_id FROM keys WHERE tg_id = $1", tg_id)
+
+ async def delete_keys_from_servers():
+ try:
+ tasks = []
+ for email, client_id in key_records:
+ servers = await get_servers_from_db()
+ for cluster_id, cluster in servers.items():
+ tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
+ await asyncio.gather(*tasks)
+ except Exception as e:
+ logger.error(f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
+
+ await delete_keys_from_servers()
+
+ try:
+ await delete_user_data(session, tg_id)
+ await callback_query.message.edit_text(
+ text=f"🗑️ Пользователь с ID {tg_id} был удален.",
+ reply_markup=build_editor_kb(callback_data.tg_id)
+ )
+ except Exception as e:
+ logger.error(f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}")
+ await callback_query.message.edit_text(
+ text=f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова."
+ )
+
+
+@router.callback_query(
+ AdminUserEditorCallback.filter(F.action == "users_editor"),
+ IsAdminFilter()
+)
+async def handle_editor(
+ callback_query: types.CallbackQuery,
+ callback_data: AdminUserEditorCallback,
+ state: FSMContext,
+ session: Any
+):
+ await process_user_search(
+ callback_query.message,
+ state,
+ session,
+ callback_data.tg_id,
+ callback_data.edit
+ )
+
+
+async def process_user_search(
+ message: types.Message,
+ state: FSMContext,
+ session: Any,
+ tg_id: int,
+ edit: bool = False
+) -> None:
+ await state.clear()
+
+ balance = await session.fetchval(
+ "SELECT balance FROM connections WHERE tg_id = $1", tg_id
+ )
+
+ if balance is None:
+ await message.answer(
+ text="🚫 Пользователь с указанным ID не найден!",
+ reply_markup=build_admin_back_kb(),
+ )
+ return
+
+ username = await session.fetchval(
+ "SELECT username FROM users WHERE tg_id = $1", tg_id
+ )
+ key_records = await session.fetch(
+ "SELECT email, expiry_time FROM keys WHERE tg_id = $1", tg_id
+ )
+ referral_count = await session.fetchval(
+ "SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
+ )
+
+ text = (
+ f"📊 Информация о пользователе"
+ f"\n\n🆔 ID: {tg_id}"
+ f"\n📄 Логин: @{username}"
+ f"\n💰 Баланс: {balance}"
+ f"\n👥 Количество рефералов: {referral_count}"
+ )
+
+ kb = build_user_edit_kb(tg_id, key_records)
+
+ if edit:
+ try:
+ await message.edit_text(
+ text=text,
+ reply_markup=kb
+ )
+ except TelegramBadRequest:
+ pass
+ else:
+ await message.answer(
+ text=text,
+ reply_markup=kb
+ )
+
+
+async def get_key_details(email, session):
+ record = await session.fetchrow(
+ """
+ SELECT k.client_id, k.key, k.expiry_time, k.server_id, c.tg_id, c.balance
+ FROM keys k
+ JOIN connections c ON k.tg_id = c.tg_id
+ WHERE k.email = $1
+ """,
+ email,
+ )
+
+ if not record:
+ return None
+
+ cluster_name = record["server_id"]
+ expiry_date = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=timezone.utc)
+
+ return {
+ "client_id": record["client_id"],
+ "balance": record["balance"],
+ "tg_id": record["tg_id"],
+ "key": record["key"],
+ "cluster_name": cluster_name,
+ "expiry_time": record["expiry_time"],
+ "expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"),
+ }
+
+
+async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exception | None:
+ client_id = await get_client_id_by_email(email)
+
+ if client_id is None:
+ return ValueError(f"User with email {email} was not found")
+
+ server_id = await session.fetchrow(
+ "SELECT server_id FROM keys WHERE client_id = $1", client_id
+ )
+
+ if not server_id:
+ return ValueError(f"User with client_id {server_id} was not found")
+
+ clusters = await get_servers_from_db()
+
+ async def update_key_on_all_servers():
+ tasks = [
+ asyncio.create_task(
+ renew_key_in_cluster(
+ cluster_name,
+ email,
+ client_id,
+ expiry_time,
+ total_gb=TOTAL_GB,
+ )
+ )
+ for cluster_name in clusters
+ ]
+
+ await asyncio.gather(*tasks)
+
+ await update_key_on_all_servers()
+ await update_key_expiry(client_id, expiry_time)
+
+
+async def get_user_balance(tg_id: int, session: Any) -> float:
+ try:
+ return await session.fetchval(
+ "SELECT balance FROM connections WHERE tg_id = $1", tg_id,
+ )
+ except Exception as e:
+ logger.error(f"Ошибка при получении баланса для пользователя {tg_id}: {e}")
+ return -1
+
+
+async def add_user_balance(tg_id: int, balance: int, session: Any) -> None:
+ try:
+ await session.execute(
+ "UPDATE connections SET balance = balance + $1 WHERE tg_id = $2",
+ balance, tg_id,
+ )
+ except Exception as e:
+ logger.error(f"Ошибка при добавлении баланса для пользователя {tg_id}: {e}")
+
+
+async def set_user_balance(tg_id: int, balance: int, session: Any) -> None:
+ try:
+ await session.execute(
+ "UPDATE connections SET balance = $1 WHERE tg_id = $2",
+ balance, tg_id,
+ )
+ except Exception as e:
+ logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
diff --git a/handlers/keys/key_utils.py b/handlers/keys/key_utils.py
index ffc42ad9..c99f84b5 100644
--- a/handlers/keys/key_utils.py
+++ b/handlers/keys/key_utils.py
@@ -1,10 +1,12 @@
import asyncio
+from typing import Any
from py3xui import AsyncApi
from client import add_client, delete_client, extend_client_key
-from config import ADMIN_PASSWORD, ADMIN_USERNAME, LIMIT_IP, SUPERNODE, TOTAL_GB
-from database import get_servers_from_db
+from config import ADMIN_PASSWORD, ADMIN_USERNAME, LIMIT_IP, SUPERNODE, TOTAL_GB, PUBLIC_LINK
+from database import get_servers_from_db, store_key
+from handlers.utils import get_least_loaded_cluster
from logger import logger
@@ -102,8 +104,6 @@ async def create_client_on_server(
if SUPERNODE:
await asyncio.sleep(0.7)
-
-
async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb):
try:
servers = await get_servers_from_db()
@@ -256,3 +256,53 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id
f"Ошибка при обновлении ключа на серверах кластера {cluster_id} для {client_id}: {e}"
)
raise e
+
+
+async def update_subscription(tg_id: int, email: str, session: Any) -> None:
+ record = await session.fetchrow(
+ """
+ SELECT k.key, k.expiry_time, k.email, k.server_id, k.client_id
+ FROM keys k
+ WHERE k.tg_id = $1 AND k.email = $2
+ """,
+ tg_id,
+ email,
+ )
+
+ if not record:
+ raise ValueError(f"The key {email} does not exist in database")
+
+ expiry_time = record["expiry_time"]
+ client_id = record["client_id"]
+ public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
+
+ await session.execute(
+ """
+ DELETE FROM keys
+ WHERE tg_id = $1 AND email = $2
+ """,
+ tg_id,
+ email,
+ )
+
+ least_loaded_cluster_id = await get_least_loaded_cluster()
+
+ await asyncio.gather(
+ update_key_on_cluster(
+ tg_id,
+ client_id,
+ email,
+ expiry_time,
+ least_loaded_cluster_id,
+ )
+ )
+
+ await store_key(
+ tg_id,
+ client_id,
+ email,
+ expiry_time,
+ public_link,
+ server_id=least_loaded_cluster_id,
+ session=session,
+ )
diff --git a/handlers/keys/keys.py b/handlers/keys/keys.py
index 28c0bbc1..b62466c7 100644
--- a/handlers/keys/keys.py
+++ b/handlers/keys/keys.py
@@ -9,6 +9,7 @@ import pytz
from aiogram import F, Router, types
from aiogram.types import BufferedInputFile, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
+from handlers.payments.yookassa_pay import process_custom_amount_input
from bot import bot
from config import (
@@ -30,7 +31,6 @@ from database import (
get_balance,
get_servers_from_db,
save_temporary_data,
- store_key,
update_balance,
update_key_expiry,
)
@@ -46,10 +46,9 @@ from handlers.keys.key_utils import (
delete_key_from_cluster,
delete_key_from_db,
renew_key_in_cluster,
- update_key_on_cluster,
+ update_subscription,
)
from handlers.payments.robokassa_pay import handle_custom_amount_input
-from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
DISCOUNTS,
KEY_NOT_FOUND_MSG,
@@ -57,7 +56,7 @@ from handlers.texts import (
SUCCESS_RENEWAL_MSG,
key_message,
)
-from handlers.utils import get_least_loaded_cluster, handle_error
+from handlers.utils import handle_error
from logger import logger
locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8")
@@ -68,7 +67,7 @@ router = Router()
@router.callback_query(F.data == "view_keys")
@router.message(F.text == "/subs")
async def process_callback_or_message_view_keys(
- callback_query_or_message: types.Message | types.CallbackQuery, session: Any
+ callback_query_or_message: types.Message | types.CallbackQuery, session: Any
):
if isinstance(callback_query_or_message, types.CallbackQuery):
chat_id = callback_query_or_message.message.chat.id
@@ -150,7 +149,7 @@ def build_keys_response(records):
async def send_with_optional_image(
- send_message, send_photo, image_path, text, keyboard
+ send_message, send_photo, image_path, text, keyboard
):
"""
Отправляет сообщение с изображением, если файл существует. В противном случае отправляет только текст.
@@ -293,67 +292,16 @@ async def process_callback_view_key(callback_query: types.CallbackQuery, session
@router.callback_query(F.data.startswith("update_subscription|"))
async def process_callback_update_subscription(
- callback_query: types.CallbackQuery, session: Any
+ callback_query: types.CallbackQuery, session: Any
):
tg_id = callback_query.message.chat.id
email = callback_query.data.split("|")[1]
+
try:
- record = await session.fetchrow(
- """
- SELECT k.key, k.expiry_time, k.email, k.server_id, k.client_id
- FROM keys k
- WHERE k.tg_id = $1 AND k.email = $2
- """,
- tg_id,
- email,
- )
-
- if record:
- expiry_time = record["expiry_time"]
- client_id = record["client_id"]
- public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
-
- try:
- await session.execute(
- """
- DELETE FROM keys
- WHERE tg_id = $1 AND email = $2
- """,
- tg_id,
- email,
- )
- except Exception as delete_error:
- await callback_query.message.answer(
- f"Ошибка при удалении старой подписки: {delete_error}",
- )
- return
-
- least_loaded_cluster_id = await get_least_loaded_cluster()
-
- await asyncio.gather(
- update_key_on_cluster(
- tg_id,
- client_id,
- email,
- expiry_time,
- least_loaded_cluster_id,
- )
- )
-
- await store_key(
- tg_id,
- client_id,
- email,
- expiry_time,
- public_link,
- server_id=least_loaded_cluster_id,
- session=session,
- )
-
- await process_callback_view_key(callback_query, session)
- else:
- await callback_query.message.answer("Ключ не найден в базе данных.")
+ await update_subscription(tg_id, email, session)
+ await process_callback_view_key(callback_query, session)
except Exception as e:
+ logger.error(f"Ошибка при обновлении ключа {email} пользователем: {e}")
await handle_error(
tg_id, callback_query, f"Ошибка при обновлении подписки: {e}"
)
@@ -450,7 +398,7 @@ async def process_callback_renew_key(callback_query: types.CallbackQuery, sessio
@router.callback_query(F.data.startswith("confirm_delete|"))
async def process_callback_confirm_delete(
- callback_query: types.CallbackQuery, session: Any
+ callback_query: types.CallbackQuery, session: Any
):
email = callback_query.data.split("|")[1]
try:
@@ -537,7 +485,8 @@ async def process_callback_renew_plan(callback_query: types.CallbackQuery, sessi
if balance < cost:
required_amount = cost - balance
- logger.info(f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}")
+ logger.info(
+ f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}")
await save_temporary_data(
session,
diff --git a/keyboards/__init__.py b/keyboards/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/keyboards/admin/__init__.py b/keyboards/admin/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/keyboards/admin/bans_kb.py b/keyboards/admin/bans_kb.py
new file mode 100644
index 00000000..958d0a14
--- /dev/null
+++ b/keyboards/admin/bans_kb.py
@@ -0,0 +1,21 @@
+from aiogram.types import InlineKeyboardMarkup
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from keyboards.admin.panel_kb import build_admin_back_btn, AdminPanelCallback
+
+
+def build_bans_kb() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="📄 Выгрузить в CSV",
+ callback_data=AdminPanelCallback(action="bans_export").pack()
+ )
+ builder.button(
+ text="🗑️ Удалить из БД",
+ callback_data=AdminPanelCallback(action="bans_delete_banned").pack()
+ )
+ builder.row(
+ build_admin_back_btn("management")
+ )
+ builder.adjust(1)
+ return builder.as_markup()
diff --git a/keyboards/admin/coupons_kb.py b/keyboards/admin/coupons_kb.py
new file mode 100644
index 00000000..41ebd8c3
--- /dev/null
+++ b/keyboards/admin/coupons_kb.py
@@ -0,0 +1,43 @@
+from aiogram.filters.callback_data import CallbackData
+from aiogram.types import InlineKeyboardMarkup
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_btn
+
+
+class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
+ coupon_code: str
+
+
+def build_coupons_kb() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="➕ Создать купон",
+ callback_data=AdminPanelCallback(action="coupons_create").pack()
+ )
+ builder.button(
+ text="Купоны",
+ callback_data=AdminPanelCallback(action="coupons_list").pack()
+ )
+ builder.row(
+ build_admin_back_btn()
+ )
+ return builder.as_markup()
+
+
+def build_coupons_list_kb(coupons: list) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+
+ for coupon in coupons:
+ coupon_code = coupon["code"]
+ builder.button(
+ text=f"❌ Удалить {coupon_code}",
+ callback_data=AdminCouponDeleteCallback(
+ coupon_code=coupon_code
+ ).pack(),
+ )
+
+ builder.row(
+ build_admin_back_btn("coupons")
+ )
+ return builder.as_markup()
diff --git a/keyboards/admin/panel_kb.py b/keyboards/admin/panel_kb.py
new file mode 100644
index 00000000..9b85a216
--- /dev/null
+++ b/keyboards/admin/panel_kb.py
@@ -0,0 +1,103 @@
+from aiogram.filters.callback_data import CallbackData
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+
+class AdminPanelCallback(CallbackData, prefix="admin_panel"):
+ action: str
+
+
+def build_panel_kb() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="👤 Поиск пользователя",
+ callback_data=AdminPanelCallback(action="search_user").pack()
+ )
+ builder.button(
+ text="🔑 Поиск по названию ключа",
+ callback_data=AdminPanelCallback(action="search_key").pack()
+ )
+ builder.row(
+ InlineKeyboardButton(
+ text="🖥️ Серверы",
+ callback_data=AdminPanelCallback(action="servers").pack()
+ ),
+ InlineKeyboardButton(
+ text="🎟️ Купоны",
+ callback_data=AdminPanelCallback(action="coupons").pack()
+ )
+ )
+ builder.button(
+ text="📢 Рассылка",
+ callback_data=AdminPanelCallback(action="sender").pack()
+ )
+ builder.row(
+ InlineKeyboardButton(
+ text="📊 Статистика",
+ callback_data=AdminPanelCallback(action="stats").pack()
+ ),
+ InlineKeyboardButton(
+ text="🤖 Управление",
+ callback_data=AdminPanelCallback(action="management").pack()
+ )
+ )
+ builder.button(
+ text="Личный кабинет",
+ callback_data="profile"
+ )
+ builder.adjust(1, 1, 2, 1, 2, 1)
+ return builder.as_markup()
+
+
+def build_management_kb() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="💾 Создать резервную копию",
+ callback_data=AdminPanelCallback(action="backups").pack()
+ )
+ builder.button(
+ text="🚫 Заблокировавшие бота",
+ callback_data=AdminPanelCallback(action="bans").pack()
+ )
+ builder.button(
+ text="🔄 Перезагрузить бота",
+ callback_data=AdminPanelCallback(action="restart").pack()
+ )
+ builder.row(
+ build_admin_back_btn()
+ )
+ builder.adjust(1)
+ return builder.as_markup()
+
+
+def build_restart_kb() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="✅ Да, перезагрузить",
+ callback_data=AdminPanelCallback(action="restart_confirm").pack()
+ )
+ builder.row(
+ build_admin_back_btn()
+ )
+ builder.adjust(1)
+ return builder.as_markup()
+
+
+def build_admin_back_kb(action: str = "admin") -> InlineKeyboardMarkup:
+ return build_admin_singleton_kb("🔙 Назад", action)
+
+
+def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.row(
+ build_admin_btn(text, action)
+ )
+ return builder.as_markup()
+
+
+def build_admin_back_btn(action: str = "admin") -> InlineKeyboardMarkup:
+ return build_admin_btn("🔙 Назад", action)
+
+
+def build_admin_btn(text: str, action: str) -> InlineKeyboardButton:
+ return InlineKeyboardButton(text=text, callback_data=AdminPanelCallback(action=action).pack())
diff --git a/keyboards/admin/sender_kb.py b/keyboards/admin/sender_kb.py
new file mode 100644
index 00000000..bfe9ad39
--- /dev/null
+++ b/keyboards/admin/sender_kb.py
@@ -0,0 +1,36 @@
+from aiogram.filters.callback_data import CallbackData
+from aiogram.types import InlineKeyboardMarkup
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from keyboards.admin.panel_kb import build_admin_back_btn
+
+
+class AdminSenderCallback(CallbackData, prefix="admin_sender"):
+ type: str
+
+
+def build_sender_kb() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="👥 Все пользователи",
+ callback_data=AdminSenderCallback(
+ type="all"
+ ).pack()
+ )
+ builder.button(
+ text="✅ Пользователи с подпиской",
+ callback_data=AdminSenderCallback(
+ type="subscribed"
+ ).pack()
+ )
+ builder.button(
+ text="❌ Пользователи без подписки",
+ callback_data=AdminSenderCallback(
+ type="unsubscribed"
+ ).pack()
+ )
+ builder.row(
+ build_admin_back_btn()
+ )
+ builder.adjust(1)
+ return builder.as_markup()
diff --git a/keyboards/admin/servers_kb.py b/keyboards/admin/servers_kb.py
new file mode 100644
index 00000000..d7dd239c
--- /dev/null
+++ b/keyboards/admin/servers_kb.py
@@ -0,0 +1,113 @@
+from aiogram.filters.callback_data import CallbackData
+from aiogram.types import InlineKeyboardMarkup
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_btn
+
+
+class AdminServerEditorCallback(CallbackData, prefix="admin_servers"):
+ action: str
+ data: str
+
+
+def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+
+ for cluster_name in servers:
+ builder.button(
+ text=f"⚙️ {cluster_name}",
+ callback_data=AdminServerEditorCallback(
+ action="clusters_manage",
+ data=cluster_name
+ ).pack()
+ )
+
+ builder.button(
+ text="➕ Добавить кластер",
+ callback_data=AdminPanelCallback(action="clusters_add").pack()
+ )
+ builder.row(
+ build_admin_back_btn()
+ )
+ builder.adjust(1)
+ return builder.as_markup()
+
+
+def build_manage_cluster_kb(cluster_servers, cluster_name) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+
+ for server in cluster_servers:
+ builder.button(
+ text=f"🌍 {server['server_name']}",
+ callback_data=AdminServerEditorCallback(
+ action="servers_manage",
+ data=server["server_name"]
+ ).pack()
+ )
+
+ builder.button(
+ text="➕ Добавить сервер",
+ callback_data=AdminServerEditorCallback(
+ action="servers_add",
+ data=cluster_name
+ ).pack()
+ )
+ builder.button(
+ text="🌐 Доступность серверов",
+ callback_data=AdminServerEditorCallback(
+ action="servers_availability",
+ data=cluster_name
+ ).pack()
+ )
+ builder.button(
+ text="💾 Создать бэкап кластера",
+ callback_data=AdminServerEditorCallback(
+ action="clusters_backup",
+ data=cluster_name
+ ).pack()
+ )
+ builder.row(
+ build_admin_back_btn("servers")
+ )
+ builder.adjust(1)
+ return builder.as_markup()
+
+
+def build_manage_server_kb(server_name: str, cluster_name: str) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="🗑️ Удалить",
+ callback_data=AdminServerEditorCallback(
+ action="servers_delete",
+ data=server_name
+ ).pack()
+ )
+ builder.button(
+ text="🔙 Назад",
+ callback_data=AdminServerEditorCallback(
+ action="clusters_manage",
+ data=cluster_name
+ ).pack()
+ )
+ builder.adjust(1)
+ return builder.as_markup()
+
+
+def build_delete_server_kb(server_name: str) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="✅ Да",
+ callback_data=AdminServerEditorCallback(
+ action="servers_delete_confirm",
+ data=server_name
+ ).pack()
+ )
+ builder.button(
+ text="🔙 Назад",
+ callback_data=AdminServerEditorCallback(
+ action="servers_manage",
+ data=server_name
+ ).pack()
+ )
+ builder.adjust(1)
+ return builder.as_markup()
diff --git a/keyboards/admin/stats_kb.py b/keyboards/admin/stats_kb.py
new file mode 100644
index 00000000..4e1db626
--- /dev/null
+++ b/keyboards/admin/stats_kb.py
@@ -0,0 +1,25 @@
+from aiogram.types import InlineKeyboardMarkup
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from keyboards.admin.panel_kb import build_admin_back_btn, AdminPanelCallback
+
+
+def build_stats_kb() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="🔄 Обновить",
+ callback_data=AdminPanelCallback(action="stats").pack()
+ )
+ builder.button(
+ text="📥 Выгрузить пользователей в CSV",
+ callback_data=AdminPanelCallback(action="stats_export_users_csv").pack()
+ )
+ builder.button(
+ text="📥 Выгрузить оплаты в CSV",
+ callback_data=AdminPanelCallback(action="stats_export_payments_csv").pack()
+ )
+ builder.row(
+ build_admin_back_btn()
+ )
+ builder.adjust(1)
+ return builder.as_markup()
diff --git a/keyboards/admin/users_kb.py b/keyboards/admin/users_kb.py
new file mode 100644
index 00000000..6eb8537c
--- /dev/null
+++ b/keyboards/admin/users_kb.py
@@ -0,0 +1,317 @@
+from datetime import datetime, timezone
+
+from aiogram.filters.callback_data import CallbackData
+from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from config import RENEWAL_PRICES
+from keyboards.admin.panel_kb import build_admin_back_btn
+
+
+class AdminUserEditorCallback(CallbackData, prefix="admin_users"):
+ action: str
+ tg_id: int
+ data: str | int | None = None
+ edit: bool = False
+
+
+class AdminUserKeyEditorCallback(CallbackData, prefix="admin_users_key"):
+ action: str
+ tg_id: int
+ data: str
+ month: int | None = None
+
+
+def build_user_edit_kb(tg_id: int, key_records: list) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ current_time = datetime.now(tz=timezone.utc)
+
+ for record in key_records:
+ email = record["email"]
+ expiry = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=timezone.utc)
+ days = (expiry - current_time).days
+ builder.button(
+ text=f"🔑 {email} ({'<1' if days < 1 else days} дн.)",
+ callback_data=AdminUserEditorCallback(
+ action="users_key_edit",
+ tg_id=tg_id,
+ data=str(email)
+ ).pack()
+ )
+
+ builder.button(
+ text="✉️ Сообщение",
+ callback_data=AdminUserEditorCallback(
+ action="users_send_message",
+ tg_id=tg_id
+ ).pack()
+ )
+ builder.button(
+ text="💸 Изменить баланс",
+ callback_data=AdminUserEditorCallback(
+ action="users_balance_edit",
+ tg_id=tg_id
+ ).pack()
+ )
+ builder.button(
+ text="♻️ Восстановить триал",
+ callback_data=AdminUserEditorCallback(
+ action="users_trial_restore",
+ tg_id=tg_id
+ ).pack()
+ )
+ builder.button(
+ text="❌ Удалить клиента",
+ callback_data=AdminUserEditorCallback(
+ action="users_delete_user",
+ tg_id=tg_id
+ ).pack()
+ )
+ builder.row(
+ build_editor_btn("🔄 Обновить данные", tg_id, edit=True)
+ )
+ builder.row(
+ build_admin_back_btn()
+ )
+ builder.adjust(1)
+ return builder.as_markup()
+
+
+def build_users_balance_change_kb(tg_id: int) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="🔙 Назад", # todo: fix magic text was set
+ callback_data=AdminUserEditorCallback(
+ action="users_balance_edit",
+ tg_id=tg_id
+ ).pack()
+ )
+ return builder.as_markup()
+
+
+def build_users_balance_kb(tg_id: int) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ for month, amount in RENEWAL_PRICES.items():
+ builder.button(
+ text=f"+ {amount}Р ({month} мес.)",
+ callback_data=AdminUserEditorCallback(
+ action="users_balance_add",
+ tg_id=tg_id,
+ data=amount
+ ).pack()
+ )
+ builder.button(
+ text=f"- {amount}Р ({month} мес.)",
+ callback_data=AdminUserEditorCallback(
+ action="users_balance_add",
+ tg_id=tg_id,
+ data=-amount
+ ).pack()
+ )
+ builder.button(
+ text="💵 Добавить",
+ callback_data=AdminUserEditorCallback(
+ action="users_balance_add",
+ tg_id=tg_id
+ ).pack()
+ )
+ builder.button(
+ text="💵 Вычесть",
+ callback_data=AdminUserEditorCallback(
+ action="users_balance_take",
+ tg_id=tg_id
+ ).pack()
+ )
+ builder.button(
+ text="💵 Установить баланс",
+ callback_data=AdminUserEditorCallback(
+ action="users_balance_set",
+ tg_id=tg_id
+ ).pack()
+ )
+ builder.row(
+ build_editor_back_btn(tg_id, True)
+ )
+ builder.adjust(2, 2, 2, 2, 2, 1)
+ return builder.as_markup()
+
+
+def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="🔙 Назад", # todo: fix magic text was set
+ callback_data=AdminUserEditorCallback(
+ action="users_key_edit",
+ tg_id=tg_id,
+ data=email,
+ edit=True
+ ).pack()
+ )
+ return builder.as_markup()
+
+
+def build_users_key_expiry_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ for month in RENEWAL_PRICES.keys():
+ month = int(month)
+ builder.button(
+ text=f"+ {month} мес.",
+ callback_data=AdminUserKeyEditorCallback(
+ action="add",
+ tg_id=tg_id,
+ data=email,
+ month=month
+ ).pack()
+ )
+ builder.button(
+ text=f"- {month} мес.",
+ callback_data=AdminUserKeyEditorCallback(
+ action="add",
+ tg_id=tg_id,
+ data=email,
+ month=-month
+ ).pack()
+ )
+ builder.button(
+ text="⏳ Добавить дни",
+ callback_data=AdminUserKeyEditorCallback(
+ action="add",
+ tg_id=tg_id,
+ data=email
+ ).pack()
+ )
+ builder.button(
+ text="⏳ Вычесть дни",
+ callback_data=AdminUserKeyEditorCallback(
+ action="take",
+ tg_id=tg_id,
+ data=email
+ ).pack()
+ )
+ builder.button(
+ text="⏳ Установить дату истечения",
+ callback_data=AdminUserKeyEditorCallback(
+ action="set",
+ tg_id=tg_id,
+ data=email
+ ).pack()
+ )
+ builder.button(
+ text="🔙 Назад", # todo: fix magic text was set
+ callback_data=AdminUserEditorCallback(
+ action="users_key_edit",
+ tg_id=tg_id,
+ data=email
+ ).pack()
+ )
+ builder.adjust(2, 2, 2, 2, 2, 1)
+ return builder.as_markup()
+
+
+def build_user_delete_kb(tg_id: int):
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="❌ Да, удалить!",
+ callback_data=AdminUserEditorCallback(
+ action="users_delete_user_confirm",
+ tg_id=tg_id
+ ).pack()
+ )
+ builder.row(
+ build_editor_back_btn(tg_id, True)
+ )
+ builder.adjust(1)
+ return builder.as_markup()
+
+
+def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text=f"🔙 Назад",
+ callback_data=AdminUserEditorCallback(
+ action="users_key_edit",
+ tg_id=tg_id,
+ data=email
+ ).pack()
+ )
+ builder.adjust(1)
+ return builder.as_markup()
+
+
+def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="⏳ Время истечения",
+ callback_data=AdminUserEditorCallback(
+ action="users_expiry_edit",
+ data=email,
+ tg_id=key_details["tg_id"]
+ ).pack()
+ )
+ builder.button(
+ text="🔄 Перевыпустить",
+ callback_data=AdminUserEditorCallback(
+ action="users_update_key",
+ data=email,
+ tg_id=key_details["tg_id"]
+ ).pack()
+ )
+ builder.button(
+ text="❌ Удалить",
+ callback_data=AdminUserEditorCallback(
+ action="users_delete_key",
+ data=email,
+ tg_id=key_details["tg_id"]
+ ).pack()
+ )
+ builder.row(
+ build_editor_back_btn(key_details["tg_id"], True)
+ )
+ builder.adjust(1)
+ return builder.as_markup()
+
+
+def build_key_delete_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.row(
+ InlineKeyboardButton(
+ text="✅ Да, удалить",
+ callback_data=AdminUserEditorCallback(
+ action="users_delete_key_confirm",
+ data=email,
+ tg_id=tg_id
+ ).pack()
+ )
+ )
+ builder.row(
+ build_editor_back_btn(tg_id)
+ )
+ builder.adjust(1)
+ return builder.as_markup()
+
+
+def build_editor_kb(tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
+ return build_editor_singleton_kb("🔙 Назад", tg_id, edit)
+
+
+def build_editor_singleton_kb(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.row(
+ build_editor_btn(text, tg_id, edit)
+ )
+ return builder.as_markup()
+
+
+def build_editor_back_btn(tg_id: int, edit: bool = False) -> InlineKeyboardButton:
+ return build_editor_btn("🔙 Назад", tg_id, edit)
+
+
+def build_editor_btn(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardButton:
+ return InlineKeyboardButton(
+ text=text,
+ callback_data=AdminUserEditorCallback(
+ action="users_editor",
+ tg_id=tg_id,
+ edit=edit
+ ).pack()
+ )
diff --git a/middlewares/delete.py b/middlewares/delete.py
index 846ed89a..29281507 100644
--- a/middlewares/delete.py
+++ b/middlewares/delete.py
@@ -4,25 +4,53 @@ from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message, TelegramObject
+from keyboards.admin.coupons_kb import AdminCouponDeleteCallback
+from keyboards.admin.panel_kb import AdminPanelCallback
+from keyboards.admin.sender_kb import AdminSenderCallback
+from keyboards.admin.servers_kb import AdminServerEditorCallback
+from keyboards.admin.users_kb import AdminUserEditorCallback, AdminUserKeyEditorCallback
+
+pass_callbacks = [
+ AdminPanelCallback,
+ AdminCouponDeleteCallback,
+ AdminSenderCallback,
+ AdminServerEditorCallback,
+ AdminUserEditorCallback,
+ AdminUserKeyEditorCallback,
+]
+
class DeleteMessageMiddleware(BaseMiddleware):
async def __call__(
- self,
- handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
- event: TelegramObject,
- data: dict[str, Any],
+ self,
+ handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
+ event: TelegramObject,
+ data: dict[str, Any],
) -> Any:
- if isinstance(event, (Message, CallbackQuery)):
- if isinstance(event, Message):
- if not event.text or not event.text.startswith("/start"):
- try:
- await event.bot.delete_message(
- event.chat.id, event.message_id - 1
- )
- except Exception:
- pass
- await event.delete()
- elif isinstance(event, CallbackQuery):
- await event.answer()
+ if isinstance(event, Message):
+ if (
+ not event.text
+ or not event.text.startswith("/start")
+ ):
+ try:
+ await event.bot.delete_message(
+ event.chat.id, event.message_id - 1
+ )
+ except Exception:
+ pass
+ await event.delete()
+
+ if isinstance(event, CallbackQuery):
+ await event.answer()
+
+ if not await self._check_callbacks(event):
await event.message.delete()
+
return await handler(event, data)
+
+ @staticmethod
+ async def _check_callbacks(event: CallbackQuery) -> bool:
+ for callback in pass_callbacks:
+ if await callback.filter()(event):
+ return True
+ return False
diff --git a/utils/__init__.py b/utils/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/utils/csv_export.py b/utils/csv_export.py
new file mode 100644
index 00000000..08cc1c59
--- /dev/null
+++ b/utils/csv_export.py
@@ -0,0 +1,101 @@
+from io import StringIO
+from typing import Any
+
+from aiogram.types import BufferedInputFile
+
+
+async def export_users_csv(session: Any) -> BufferedInputFile:
+ # Получение данных о платежах пользователя
+ query = """
+ SELECT
+ u.tg_id,
+ u.username,
+ u.first_name,
+ u.last_name,
+ u.language_code,
+ u.is_bot,
+ c.balance,
+ c.trial
+ FROM users u
+ LEFT JOIN connections c ON u.tg_id = c.tg_id
+ """
+
+ users = await session.fetch(query)
+
+ buffer = StringIO()
+ buffer.write("tg_id,username,first_name,last_name,language_code,is_bot,balance,trial\n")
+
+ # Запись данных
+ for user in users:
+ buffer.write(
+ f"{user['tg_id']},{user['username']},{user['first_name']},{user['last_name']},"
+ f"{user['language_code']},{user['is_bot']},{user['balance']},{user['trial']}\n"
+ )
+
+ # Перемещение указателя в начало для чтения
+ buffer.seek(0)
+
+ return BufferedInputFile(
+ file=buffer.getvalue().encode("utf-8-sig"),
+ filename="users_export.csv"
+ )
+
+
+async def export_payments_csv(session: Any) -> BufferedInputFile:
+ # Получение данных о всех платежах
+ query = """
+ SELECT
+ u.tg_id,
+ u.username,
+ u.first_name,
+ u.last_name,
+ p.amount,
+ p.payment_system,
+ p.status,
+ p.created_at
+ FROM users u
+ JOIN payments p ON u.tg_id = p.tg_id
+ """
+ payments = await session.fetch(query)
+ return _export_payments_csv(payments, "payments_export.csv")
+
+
+async def export_user_payments_csv(tg_id: int, session: Any) -> BufferedInputFile:
+ # Получение данных о платежах пользователя
+ query = """
+ SELECT
+ u.tg_id,
+ u.username,
+ u.first_name,
+ u.last_name,
+ p.amount,
+ p.payment_system,
+ p.status,
+ p.created_at
+ FROM users u
+ JOIN payments p ON u.tg_id = p.tg_id
+ WHERE u.tg_id = $1
+ """
+ payments = await session.fetch(query, tg_id)
+ return _export_payments_csv(payments, f"payments_export_{tg_id}.csv")
+
+
+def _export_payments_csv(payments: list, filename: str) -> BufferedInputFile:
+ # Формирование CSV данных через StringIO
+ buffer = StringIO()
+ buffer.write("tg_id,username,first_name,last_name,amount,payment_system,status,created_at\n")
+
+ # Запись данных
+ for payment in payments:
+ buffer.write(
+ f"{payment['tg_id']},{payment['username']},{payment['first_name']},{payment['last_name']},"
+ f"{payment['amount']},{payment['payment_system']},{payment['status']},{payment['created_at']}\n"
+ )
+
+ # Перемещение указателя в начало для чтения
+ buffer.seek(0)
+
+ return BufferedInputFile(
+ file=buffer.getvalue().encode("utf-8-sig"),
+ filename=filename
+ )