diff --git a/.flake8 b/.flake8 index 5b3aa68f..68f40a2d 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] max-line-length = 250 -ignore = E203, E266, E501, W503, F541, E704, W293, W291, E126, E121, E123, E128, E302, E131, E231, W292, E402, E261, E305 +ignore = E203, E266, E501, W503, F541, E704, W293, W291, E126, E121, E123, E128, E302, E131, E231, W292, E402, E261, E305, E701 max-complexity = 25 select = B, C, E, F, W, T4, B9 exclude = .venv,.git,.tox,dist,doc,*lib/python*,*egg,build,.txt \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2c9c052d..2b66b41b 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,5 @@ handlers/texts.py Thumbs.db nginx.conf -scripts/nginx.sh +scripts +models.py \ No newline at end of file diff --git a/backup.py b/backup.py index 27839beb..9a71da8b 100644 --- a/backup.py +++ b/backup.py @@ -1,6 +1,7 @@ import os import subprocess from datetime import datetime +from typing import Union from aiogram.types import BufferedInputFile @@ -11,6 +12,15 @@ from logger import logger async def backup_database(): from bot import bot + try: + if backup_file_path := _create_database_backup(): + await _send_backup_to_admin(bot, backup_file_path) + _cleanup_old_backups() + except Exception as e: + logger.error(f"Ошибка при создании или отправке бэкапа: {e}") + + +def _create_database_backup(): USER = DB_USER HOST = "localhost" BACKUP_DIR = BACK_DIR @@ -25,25 +35,37 @@ async def backup_database(): check=True, ) logger.info(f"Бэкап базы данных создан: {BACKUP_FILE}") + return BACKUP_FILE except subprocess.CalledProcessError as e: logger.error(f"Ошибка при создании бэкапа базы данных: {e}") - return + return None + finally: + del os.environ["PGPASSWORD"] + +async def _send_backup_to_admin(bot, backup_file_path): try: - with open(BACKUP_FILE, "rb") as backup_file: + with open(backup_file_path, "rb") as backup_file: backup_input_file = BufferedInputFile( - backup_file.read(), filename=os.path.basename(BACKUP_FILE) + backup_file.read(), filename=os.path.basename(backup_file_path) ) - await bot.send_document(ADMIN_ID, backup_input_file) + admin_ids: Union[int, list[int]] = ADMIN_ID + if isinstance(admin_ids, list): + for id in admin_ids: + await bot.send_document(id, backup_input_file) + 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(): try: subprocess.run( [ "find", - BACKUP_DIR, + BACK_DIR, "-type", "f", "-name", @@ -60,5 +82,3 @@ async def backup_database(): logger.info("Старые бэкапы удалены.") except subprocess.CalledProcessError as e: logger.error(f"Ошибка при удалении старых бэкапов: {e}") - - del os.environ["PGPASSWORD"] diff --git a/bot.py b/bot.py index 63264af4..310fa61d 100644 --- a/bot.py +++ b/bot.py @@ -2,8 +2,9 @@ from aiogram import Bot, Dispatcher, Router from aiogram.fsm.storage.memory import MemoryStorage from config import API_TOKEN, CRYPTO_BOT_ENABLE, FREEKASSA_ENABLE, ROBOKASSA_ENABLE, STARS_ENABLE, YOOKASSA_ENABLE -from middlewares.database import DatabaseMiddleware +from middlewares.admin import AdminMiddleware from middlewares.logging import LoggingMiddleware +from middlewares.user import UserMiddleware bot = Bot(token=API_TOKEN) storage = MemoryStorage() @@ -44,6 +45,8 @@ if ROBOKASSA_ENABLE: dp.message.middleware(LoggingMiddleware()) dp.callback_query.middleware(LoggingMiddleware()) +dp.message.middleware(AdminMiddleware()) +dp.callback_query.middleware(AdminMiddleware()) -dp.message.middleware(DatabaseMiddleware()) -dp.callback_query.middleware(DatabaseMiddleware()) +dp.message.middleware(UserMiddleware()) +dp.callback_query.middleware(UserMiddleware()) diff --git a/database.py b/database.py index 186c77ea..69a2c956 100644 --- a/database.py +++ b/database.py @@ -9,6 +9,22 @@ from logger import logger async def init_db(): conn = await asyncpg.connect(DATABASE_URL) + # Таблица для хранения основной информации о пользователях из Telegram + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + tg_id BIGINT PRIMARY KEY NOT NULL, + username TEXT, + first_name TEXT, + last_name TEXT, + language_code TEXT, + is_bot BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + # Таблица для хранения информации о пользователях await conn.execute( """ @@ -420,3 +436,58 @@ async def get_tg_id_by_client_id(client_id: str): return result["tg_id"] if result else None finally: await conn.close() + + +async def upsert_user( + tg_id: int, + username: str = None, + first_name: str = None, + last_name: str = None, + language_code: str = None, + is_bot: bool = False, +): + """ + Создает или обновляет информацию о пользователе в базе данных. + + Args: + tg_id (int): Уникальный идентификатор пользователя в Telegram + username (str, optional): Никнейм пользователя + first_name (str, optional): Имя пользователя + last_name (str, optional): Фамилия пользователя + language_code (str, optional): Код языка пользователя + is_bot (bool, optional): Флаг, указывающий является ли пользователь ботом + """ + conn = await asyncpg.connect(DATABASE_URL) + try: + await conn.execute( + """ + INSERT INTO users (tg_id, username, first_name, last_name, language_code, is_bot, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (tg_id) DO UPDATE + SET + username = COALESCE(EXCLUDED.username, users.username), + first_name = COALESCE(EXCLUDED.first_name, users.first_name), + last_name = COALESCE(EXCLUDED.last_name, users.last_name), + language_code = COALESCE(EXCLUDED.language_code, users.language_code), + is_bot = EXCLUDED.is_bot, + updated_at = CURRENT_TIMESTAMP + """, + tg_id, + username, + first_name, + last_name, + language_code, + is_bot, + ) + + # Создаем запись в connections, если ее еще нет + await conn.execute( + """ + INSERT INTO connections (tg_id, balance, trial) + VALUES ($1, 0.0, 0) + ON CONFLICT (tg_id) DO NOTHING + """, + tg_id, + ) + finally: + await conn.close() diff --git a/filters/admin.py b/filters/admin.py index 7ecd1f7d..17eddb73 100644 --- a/filters/admin.py +++ b/filters/admin.py @@ -1,3 +1,5 @@ +from typing import Union + from aiogram.filters import BaseFilter from aiogram.types import Message @@ -6,9 +8,13 @@ from config import ADMIN_ID class IsAdminFilter(BaseFilter): async def __call__(self, message: Message) -> bool: - if isinstance(ADMIN_ID, list): - return message.from_user.id in ADMIN_ID - elif isinstance(ADMIN_ID, int): - return message.from_user.id == ADMIN_ID - else: + try: + admin_ids: Union[int, list[int]] = ADMIN_ID + + if isinstance(admin_ids, list): + return message.from_user.id in admin_ids + + return message.from_user.id == admin_ids + + except Exception: return False diff --git a/handlers/admin/admin_coupons.py b/handlers/admin/admin_coupons.py index 86f581b1..6c786cd3 100644 --- a/handlers/admin/admin_coupons.py +++ b/handlers/admin/admin_coupons.py @@ -5,6 +5,7 @@ from aiogram.types import InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder from database import create_coupon, delete_coupon_from_db, get_all_coupons +from filters.admin import IsAdminFilter from logger import logger @@ -15,7 +16,7 @@ class AdminCouponsState(StatesGroup): router = Router() -@router.callback_query(F.data == "coupons_editor") +@router.callback_query(F.data == "coupons_editor", IsAdminFilter()) async def show_coupon_management_menu(callback_query: types.CallbackQuery): try: await callback_query.message.delete() @@ -38,7 +39,7 @@ async def show_coupon_management_menu(callback_query: types.CallbackQuery): await callback_query.answer() -@router.callback_query(F.data == "coupons") +@router.callback_query(F.data == "coupons", IsAdminFilter()) async def show_coupon_list(callback_query: types.CallbackQuery): try: try: @@ -99,7 +100,7 @@ async def show_coupon_list(callback_query: types.CallbackQuery): await callback_query.answer() -@router.callback_query(F.data.startswith("delete_coupon_")) +@router.callback_query(F.data.startswith("delete_coupon_"), IsAdminFilter()) async def handle_delete_coupon(callback_query: types.CallbackQuery): coupon_code = callback_query.data[len("delete_coupon_") :] @@ -127,24 +128,29 @@ async def handle_delete_coupon(callback_query: types.CallbackQuery): await callback_query.answer() -@router.callback_query(F.data == "create_coupon") +@router.callback_query(F.data == "create_coupon", IsAdminFilter()) async def handle_create_coupon(callback_query: types.CallbackQuery, state: FSMContext): try: await callback_query.message.delete() except Exception as e: logger.error(f"Ошибка при удалении сообщения: {e}") + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")) + markup = builder.as_markup() + await callback_query.message.answer( "Введите данные для создания купона в формате:\n\n" "код сумма лимит\n\n" "Пример: 'COUPON1 50 5'\n\n", parse_mode="HTML", + reply_markup=markup, ) await state.set_state(AdminCouponsState.waiting_for_coupon_data) await callback_query.answer() -@router.message(AdminCouponsState.waiting_for_coupon_data) +@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter()) async def process_coupon_data(message: types.Message, state: FSMContext): text = message.text.strip() @@ -202,6 +208,7 @@ async def process_coupon_data(message: types.Message, state: FSMContext): @router.callback_query(F.data == "back_to_coupons_menu") -async def back_to_coupons_menu(callback_query: types.CallbackQuery): +async def back_to_coupons_menu(callback_query: types.CallbackQuery, state: FSMContext): """Возвращаем пользователя в меню управления купонами""" + await state.clear() await show_coupon_management_menu(callback_query) diff --git a/handlers/admin/admin_panel.py b/handlers/admin/admin_panel.py index c840524f..414fdbd0 100644 --- a/handlers/admin/admin_panel.py +++ b/handlers/admin/admin_panel.py @@ -23,8 +23,13 @@ class UserEditorState(StatesGroup): displaying_user_info = State() -@router.message(Command("admin"), IsAdminFilter()) -async def handle_admin_command(message: types.Message): +@router.callback_query(F.data == "admin", IsAdminFilter()) +async def handle_admin_callback_query(callback_query: CallbackQuery): + await handle_admin_message(callback_query.message) + + +@router.message(Command("admin"), F.data == "admin", IsAdminFilter()) +async def handle_admin_message(message: types.Message): builder = InlineKeyboardBuilder() builder.row( InlineKeyboardButton( @@ -50,6 +55,9 @@ async def handle_admin_command(message: types.Message): builder.row( InlineKeyboardButton(text="🔄 Перезагрузить бота", callback_data="restart_bot") ) + builder.row( + InlineKeyboardButton(text="⬅️ Вернуться в профиль", callback_data="view_profile") + ) await bot.send_message( message.chat.id, "🤖 Панель администратора", reply_markup=builder.as_markup() ) diff --git a/handlers/commands.py b/handlers/commands.py index aa064a22..3ecebb00 100644 --- a/handlers/commands.py +++ b/handlers/commands.py @@ -8,10 +8,10 @@ router = Router() @router.message(Command("start")) -async def handle_start(message: types.Message, state: FSMContext): - await start_command(message) +async def handle_start(message: types.Message, state: FSMContext, admin: bool = False): + await start_command(message, admin) @router.message(Command("menu")) -async def handle_menu(message: types.Message, state: FSMContext): - await start_command(message) +async def handle_menu(message: types.Message, state: FSMContext, admin: bool = False): + await start_command(message, admin) diff --git a/handlers/instructions/instructions.jpg b/handlers/instructions/instructions.jpg deleted file mode 100644 index 9c52d9f6..00000000 Binary files a/handlers/instructions/instructions.jpg and /dev/null differ diff --git a/handlers/instructions/instructions.py b/handlers/instructions/instructions.py index 458af5b5..3cf8acfa 100644 --- a/handlers/instructions/instructions.py +++ b/handlers/instructions/instructions.py @@ -16,7 +16,7 @@ async def send_instructions(callback_query: types.CallbackQuery): instructions_message = INSTRUCTIONS - image_path = os.path.join(os.path.dirname(__file__), "instructions.jpg") + image_path = os.path.join("img", "instructions.jpg") if not os.path.isfile(image_path): await callback_query.message.answer("Файл изображения не найден.") diff --git a/handlers/keys/key_management.py b/handlers/keys/key_management.py index de856809..c04c63bf 100644 --- a/handlers/keys/key_management.py +++ b/handlers/keys/key_management.py @@ -9,7 +9,7 @@ from aiogram.fsm.state import State, StatesGroup from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message from bot import bot, dp -from config import CONNECT_ANDROID, CONNECT_IOS, DATABASE_URL, DOWNLOAD_ANDROID, DOWNLOAD_IOS, PUBLIC_LINK +from config import CONNECT_ANDROID, CONNECT_IOS, DATABASE_URL, DOWNLOAD_ANDROID, DOWNLOAD_IOS, PUBLIC_LINK, SUPPORT_CHAT_URL from database import add_connection, get_balance, store_key, update_balance from handlers.instructions.instructions import send_instructions from handlers.keys.key_utils import create_key_on_cluster @@ -202,6 +202,8 @@ async def handle_key_name_input(message: Message, state: FSMContext): logger.info(f"Generated public link for the key: {public_link}") + button_support = InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL) + button_profile = InlineKeyboardButton( text="👤 Личный кабинет", callback_data="view_profile" ) @@ -221,6 +223,7 @@ async def handle_key_name_input(message: Message, state: FSMContext): keyboard = InlineKeyboardMarkup( inline_keyboard=[ + [button_support], [button_download_ios, button_download_android], [button_iphone, button_android], [button_profile], diff --git a/handlers/keys/keys.py b/handlers/keys/keys.py index e458fb1a..c2dff394 100644 --- a/handlers/keys/keys.py +++ b/handlers/keys/keys.py @@ -56,7 +56,7 @@ async def process_callback_view_keys(callback_query: types.CallbackQuery): "👇 Выберите устройство для управления подпиской:" ) - image_path = os.path.join(os.path.dirname(__file__), "pic_keys.jpg") + image_path = os.path.join("img", "pic_keys.jpg") try: await bot.delete_message( @@ -104,7 +104,7 @@ async def process_callback_view_keys(callback_query: types.CallbackQuery): except Exception as e: logger.error(f"Ошибка при удалении сообщения: {e}") - image_path = os.path.join(os.path.dirname(__file__), "pic_keys.jpg") + image_path = os.path.join("img", "pic_keys.jpg") if os.path.isfile(image_path): with open(image_path, "rb") as image_file: @@ -231,7 +231,7 @@ async def process_callback_view_key(callback_query: types.CallbackQuery): keyboard = types.InlineKeyboardMarkup(inline_keyboard=inline_keyboard) - image_path = os.path.join(os.path.dirname(__file__), "pic_view.jpg") + image_path = os.path.join("img", "pic_view.jpg") if not os.path.isfile(image_path): await bot.send_message(tg_id, "Файл изображения не найден.") diff --git a/handlers/keys/pic_keys.jpg b/handlers/keys/pic_keys.jpg deleted file mode 100644 index a6ec6b6b..00000000 Binary files a/handlers/keys/pic_keys.jpg and /dev/null differ diff --git a/handlers/pay.py b/handlers/pay.py index c09cb66e..91757911 100644 --- a/handlers/pay.py +++ b/handlers/pay.py @@ -72,8 +72,8 @@ async def handle_pay(callback_query: CallbackQuery): @router.callback_query(F.data == "back_to_menu") -async def handle_back_to_menu(callback_query: CallbackQuery): +async def handle_back_to_menu(callback_query: CallbackQuery, admin: bool = False): await callback_query.message.delete() trial_status = await get_trial(callback_query.from_user.id) - await send_welcome_message(callback_query.from_user.id, trial_status) + await send_welcome_message(callback_query.from_user.id, trial_status, admin) await callback_query.answer() diff --git a/handlers/payments/robokassa_pay.py b/handlers/payments/robokassa_pay.py index df91e973..a7809d1f 100644 --- a/handlers/payments/robokassa_pay.py +++ b/handlers/payments/robokassa_pay.py @@ -10,7 +10,7 @@ from loguru import logger from robokassa import HashAlgorithm, Robokassa from bot import bot -from config import ROBOKASSA_LOGIN, ROBOKASSA_PASSWORD1, ROBOKASSA_PASSWORD2, ROBOKASSA_TEST_MODE +from config import ROBOKASSA_ENABLE, ROBOKASSA_LOGIN, ROBOKASSA_PASSWORD1, ROBOKASSA_PASSWORD2, ROBOKASSA_TEST_MODE from database import add_connection, check_connection_exists, get_key_count, update_balance from handlers.texts import PAYMENT_OPTIONS @@ -22,15 +22,16 @@ class ReplenishBalanceState(StatesGroup): waiting_for_payment_confirmation_robokassa = State() -robokassa = Robokassa( - merchant_login=ROBOKASSA_LOGIN, - password1=ROBOKASSA_PASSWORD1, - password2=ROBOKASSA_PASSWORD2, - algorithm=HashAlgorithm.md5, - is_test=ROBOKASSA_TEST_MODE, -) +if ROBOKASSA_ENABLE: + robokassa = Robokassa( + merchant_login=ROBOKASSA_LOGIN, + password1=ROBOKASSA_PASSWORD1, + password2=ROBOKASSA_PASSWORD2, + algorithm=HashAlgorithm.md5, + is_test=ROBOKASSA_TEST_MODE, + ) -logger.info("Robokassa initialized with login: {}", ROBOKASSA_LOGIN) + logger.info("Robokassa initialized with login: {}", ROBOKASSA_LOGIN) def generate_payment_link(amount, inv_id, description): diff --git a/handlers/payments/stars_pay.py b/handlers/payments/stars_pay.py index a628d3b0..1f9607f3 100644 --- a/handlers/payments/stars_pay.py +++ b/handlers/payments/stars_pay.py @@ -51,6 +51,11 @@ async def process_callback_pay_stars( tg_id = callback_query.from_user.id builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot" + ) + ) for i in range(0, len(PAYMENT_OPTIONS), 2): if i + 1 < len(PAYMENT_OPTIONS): @@ -76,11 +81,6 @@ async def process_callback_pay_stars( text="💰 Ввести свою сумму", callback_data="enter_custom_amount_stars" ) ) - builder.row( - InlineKeyboardButton( - text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot" - ) - ) builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="pay")) key_count = await get_key_count(tg_id) diff --git a/handlers/profile.py b/handlers/profile.py index 7958a5b5..a928f103 100644 --- a/handlers/profile.py +++ b/handlers/profile.py @@ -15,12 +15,12 @@ router = Router() async def process_callback_view_profile( - callback_query: types.CallbackQuery, state: FSMContext + callback_query: types.CallbackQuery, state: FSMContext, admin: bool ): chat_id = callback_query.from_user.id username = callback_query.from_user.full_name - image_path = os.path.join(os.path.dirname(__file__), "pic.jpg") + image_path = os.path.join("img", "pic.jpg") try: key_count = await get_key_count(chat_id) @@ -52,6 +52,10 @@ async def process_callback_view_profile( builder.row( InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate") ) + if admin: + builder.row( + InlineKeyboardButton(text="🔧 Администратор", callback_data="admin") + ) builder.row( InlineKeyboardButton(text="⬅️ Главное меню", callback_data="back_to_menu") ) @@ -95,7 +99,7 @@ async def invite_handler(callback_query: types.CallbackQuery): invite_message = invite_message_send(referral_link, referral_stats) - image_path = os.path.join(os.path.dirname(__file__), "pic_invite.jpg") + image_path = os.path.join("img", "pic_invite.jpg") builder = InlineKeyboardBuilder() builder.row( @@ -138,6 +142,8 @@ async def invite_handler(callback_query: types.CallbackQuery): @router.callback_query(F.data == "view_profile") -async def view_profile_handler(callback_query: types.CallbackQuery, state: FSMContext): +async def view_profile_handler( + callback_query: types.CallbackQuery, state: FSMContext, admin: bool = False +): await state.clear() - await process_callback_view_profile(callback_query, state) + await process_callback_view_profile(callback_query, state, admin) diff --git a/handlers/start.py b/handlers/start.py index 24833c99..bdee0d5b 100644 --- a/handlers/start.py +++ b/handlers/start.py @@ -1,7 +1,6 @@ import os from aiogram import F, Router -from aiogram.filters import Command from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton, Message from aiogram.utils.keyboard import InlineKeyboardBuilder @@ -15,8 +14,8 @@ from logger import logger router = Router() -async def send_welcome_message(chat_id: int, trial_status: int): - image_path = os.path.join(os.path.dirname(__file__), "pic.jpg") +async def send_welcome_message(chat_id: int, trial_status: int, admin: bool): + image_path = os.path.join("img", "pic.jpg") builder = InlineKeyboardBuilder() if trial_status == 0: @@ -26,6 +25,10 @@ async def send_welcome_message(chat_id: int, trial_status: int): builder.row( InlineKeyboardButton(text="👤 Личный кабинет", callback_data="view_profile") ) + if admin: + builder.row( + InlineKeyboardButton(text="🔧 Администратор", callback_data="admin") + ) builder.row( InlineKeyboardButton(text="📞 Техническая поддержка", url=SUPPORT_CHAT_URL), ) @@ -52,8 +55,7 @@ async def send_welcome_message(chat_id: int, trial_status: int): ) -@router.message(Command("start")) -async def start_command(message: Message): +async def start_command(message: Message, admin: bool = False): logger.info(f"Received start command with text: {message.text}") if "referral_" in message.text: referrer_tg_id = int(message.text.split("referral_")[1]) @@ -66,7 +68,7 @@ async def start_command(message: Message): await message.answer("Вы уже зарегистрированы в системе!") trial_status = await get_trial(message.from_user.id) - await send_welcome_message(message.chat.id, trial_status) + await send_welcome_message(message.chat.id, trial_status, admin) @router.callback_query(F.data == "connect_vpn") @@ -150,8 +152,8 @@ async def handle_about_vpn(callback_query: CallbackQuery): @router.callback_query(F.data == "back_to_menu") -async def handle_back_to_menu(callback_query: CallbackQuery): +async def handle_back_to_menu(callback_query: CallbackQuery, admin: bool = False): await callback_query.message.delete() trial_status = await get_trial(callback_query.from_user.id) - await send_welcome_message(callback_query.from_user.id, trial_status) + await send_welcome_message(callback_query.from_user.id, trial_status, admin) await callback_query.answer() diff --git a/handlers/utils.py b/handlers/utils.py index a4cafd30..2eb80d9f 100644 --- a/handlers/utils.py +++ b/handlers/utils.py @@ -1,5 +1,6 @@ import random import re +from typing import Optional import asyncpg @@ -9,35 +10,48 @@ from logger import logger def sanitize_key_name(key_name: str) -> str: + """ + Очищает название ключа, оставляя только допустимые символы. + + Args: + key_name (str): Исходное название ключа. + + Returns: + str: Очищенное название ключа в нижнем регистре. + """ return re.sub(r"[^a-z0-9@._-]", "", key_name.lower()) -def generate_random_email(): - """Генерирует случайный набор символов.""" - random_string = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=6)) - return random_string - - -async def get_least_loaded_cluster(): +def generate_random_email(length: int = 6) -> str: """ - Функция для получения кластера с наименьшей загрузкой (по количеству ключей). - Возвращает идентификатор кластера с наименьшей загрузкой или первый кластер из конфигурации, - если загруженность не определяется. В случае отсутствия кластеров с номером, возвращает 'cluster1'. + Генерирует случайный email с заданной длиной. + + Args: + length (int, optional): Длина случайной строки. По умолчанию 6. + + Returns: + str: Сгенерированная случайная строка. """ - cluster_loads = {} + return "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=length)) - conn = await asyncpg.connect(DATABASE_URL) - try: - keys = await conn.fetch("SELECT * FROM keys") - for key in keys: - cluster_id = key["server_id"] - if re.match(r"^cluster\d+$", cluster_id): - if cluster_id not in cluster_loads: - cluster_loads[cluster_id] = 0 - cluster_loads[cluster_id] += 1 - finally: - await conn.close() +async def get_least_loaded_cluster() -> str: + """ + Определяет кластер с наименьшей загрузкой. + + Returns: + str: Идентификатор наименее загруженного кластера. + """ + cluster_loads: dict[str, int] = {} + + async with asyncpg.create_pool(DATABASE_URL) as pool: + async with pool.acquire() as conn: + keys = await conn.fetch("SELECT * FROM keys") + + for key in keys: + cluster_id = key["server_id"] + if re.match(r"^cluster\d+$", cluster_id): + cluster_loads[cluster_id] = cluster_loads.get(cluster_id, 0) + 1 logger.info(f"Cluster loads: {cluster_loads}") @@ -51,29 +65,38 @@ async def get_least_loaded_cluster(): logger.info(f"Available clusters from config: {available_clusters}") if available_clusters: - logger.info( - f"Returning the first available cluster: {available_clusters[0]}" - ) - return available_clusters[0] - else: - logger.warning("No valid clusters found in config, returning 'cluster1'.") - return "cluster1" + selected_cluster = available_clusters[0] + logger.info(f"Returning the first available cluster: {selected_cluster}") + return selected_cluster + + logger.warning("No valid clusters found in config, returning 'cluster1'.") + return "cluster1" least_loaded_cluster = min(cluster_loads, key=cluster_loads.get) - logger.info(f"Least loaded cluster selected: {least_loaded_cluster}") return least_loaded_cluster -async def handle_error(tg_id, callback_query, message): +async def handle_error( + tg_id: int, callback_query: Optional[object] = None, message: str = "" +) -> None: + """ + Обрабатывает ошибку, отправляя сообщение пользователю. + + Args: + tg_id (int): Идентификатор пользователя в Telegram. + callback_query (Optional[object], optional): Объект запроса обратного вызова. По умолчанию None. + message (str, optional): Текст сообщения об ошибке. По умолчанию пустая строка. + """ try: - try: - await bot.delete_message( - chat_id=tg_id, message_id=callback_query.message.message_id - ) - except Exception: - pass + if callback_query and hasattr(callback_query, "message"): + try: + await bot.delete_message( + chat_id=tg_id, message_id=callback_query.message.message_id + ) + except Exception as delete_error: + logger.warning(f"Не удалось удалить сообщение: {delete_error}") await bot.send_message(tg_id, message, parse_mode="HTML") diff --git a/handlers/keys/pic_view.jpg b/img/instructions.jpg similarity index 100% rename from handlers/keys/pic_view.jpg rename to img/instructions.jpg diff --git a/handlers/pic.jpg b/img/pic.jpg similarity index 100% rename from handlers/pic.jpg rename to img/pic.jpg diff --git a/handlers/pic_invite.jpg b/img/pic_invite.jpg similarity index 100% rename from handlers/pic_invite.jpg rename to img/pic_invite.jpg diff --git a/img/pic_keys.jpg b/img/pic_keys.jpg new file mode 100644 index 00000000..1064f788 Binary files /dev/null and b/img/pic_keys.jpg differ diff --git a/img/pic_view.jpg b/img/pic_view.jpg new file mode 100644 index 00000000..1064f788 Binary files /dev/null and b/img/pic_view.jpg differ diff --git a/middlewares/admin.py b/middlewares/admin.py new file mode 100644 index 00000000..0ae5f51a --- /dev/null +++ b/middlewares/admin.py @@ -0,0 +1,29 @@ +from typing import Any, Awaitable, Callable, Dict,Union + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject + +from config import ADMIN_ID +from logger import logger + + +class AdminMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any], + ) -> Any: + data["admin"] = self._check_admin_access(event) + return await handler(event, data) + + def _check_admin_access(self, event: TelegramObject) -> bool: + try: + admin_ids: Union[int, list[int]] = ADMIN_ID + + if isinstance(admin_ids, list): + return event.from_user.id in admin_ids + return event.from_user.id == admin_ids + except Exception as e: + logger.error(f"Ошибка проверки администратора: {e}") + return False diff --git a/middlewares/database.py b/middlewares/database.py index 98482283..0244a0d8 100644 --- a/middlewares/database.py +++ b/middlewares/database.py @@ -14,9 +14,10 @@ class DatabaseMiddleware(BaseMiddleware): event: TelegramObject, data: Dict[str, Any], ) -> Any: - session = await asyncpg.connect(DATABASE_URL) - data["session"] = session - try: - return await handler(event, data) - finally: - await session.close() + async with await asyncpg.create_pool(DATABASE_URL) as pool: + async with pool.acquire() as session: + data["session"] = session + try: + return await handler(event, data) + finally: + await pool.release(session) diff --git a/middlewares/logging.py b/middlewares/logging.py index 9325a555..fbe31bae 100644 --- a/middlewares/logging.py +++ b/middlewares/logging.py @@ -1,4 +1,4 @@ -from typing import Any, Awaitable, Callable, Dict +from typing import Any, Awaitable, Callable, Dict, Optional from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, Message, TelegramObject @@ -13,23 +13,30 @@ class LoggingMiddleware(BaseMiddleware): event: TelegramObject, data: Dict[str, Any], ) -> Any: + user_info = self._extract_user_info(event) + + logger.info( + f"Активность пользователя - " + f"ID пользователя: {user_info['user_id']}, " + f"Имя пользователя: {user_info['username']}, " + f"Действие: {user_info['action']}" + ) + return await handler(event, data) + + def _extract_user_info(self, event: TelegramObject) -> Dict[str, Optional[str]]: user_id = None username = None action = None if isinstance(event, Message): - user_id = event.from_user.id - username = event.from_user.username + user = event.from_user + user_id = user.id + username = user.username action = f"Сообщение: {event.text}" elif isinstance(event, CallbackQuery): - user_id = event.from_user.id - username = event.from_user.username + user = event.from_user + user_id = user.id + username = user.username action = f"Обратный вызов: {event.data}" - logger.info( - f"Активность пользователя - " - f"ID пользователя: {user_id}, " - f"Имя пользователя: {username}, " - f"Действие: {action}" - ) - return await handler(event, data) + return {"user_id": user_id, "username": username, "action": action} diff --git a/middlewares/user.py b/middlewares/user.py new file mode 100644 index 00000000..969020ef --- /dev/null +++ b/middlewares/user.py @@ -0,0 +1,28 @@ +from typing import Any, Awaitable, Callable, Dict + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, User + +from database import upsert_user + + +class UserMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any], + ) -> Any: + if user := data.get("event_from_user"): + await self._process_user(user) + return await handler(event, data) + + async def _process_user(self, user: User) -> None: + await upsert_user( + tg_id=user.id, + username=user.username, + first_name=user.first_name, + last_name=user.last_name, + language_code=user.language_code, + is_bot=user.is_bot, + ) diff --git a/models.py b/models.py deleted file mode 100644 index ee997b3a..00000000 --- a/models.py +++ /dev/null @@ -1,70 +0,0 @@ -from sqlalchemy import BigInteger, Boolean, Column, Float, ForeignKey, Text -from sqlalchemy.orm import declarative_base, relationship - -Base = declarative_base() - - -class Connection(Base): - """ - Модель для таблицы connections - """ - - __tablename__ = "connections" - - tg_id = Column(BigInteger, primary_key=True, nullable=False) - balance = Column(Float, nullable=False, default=0.0) - trial = Column(BigInteger, nullable=False, default=0) - - # Связь с ключами и рефералами - keys = relationship("Key", back_populates="connection") - referrals_received = relationship( - "Referral", foreign_keys="Referral.referred_tg_id", back_populates="referred" - ) - referrals_sent = relationship( - "Referral", foreign_keys="Referral.referrer_tg_id", back_populates="referrer" - ) - - -class Key(Base): - """ - Модель для таблицы keys - """ - - __tablename__ = "keys" - - tg_id = Column( - BigInteger, ForeignKey("connections.tg_id"), primary_key=True, nullable=False - ) - client_id = Column(Text, primary_key=True, nullable=False) - email = Column(Text, nullable=False) - created_at = Column(BigInteger, nullable=False) - expiry_time = Column(BigInteger, nullable=False) - key = Column(Text, nullable=False) - server_id = Column(Text, nullable=False, default="server1") - notified = Column(Boolean, nullable=False, default=False) - notified_24h = Column(Boolean, nullable=False, default=False) - - # Связь с подключением - connection = relationship("Connection", back_populates="keys") - - -class Referral(Base): - """ - Модель для таблицы referrals - """ - - __tablename__ = "referrals" - - referred_tg_id = Column( - BigInteger, ForeignKey("connections.tg_id"), primary_key=True, nullable=False - ) - referrer_tg_id = Column(BigInteger, ForeignKey("connections.tg_id"), nullable=False) - reward_issued = Column(Boolean, default=False) - - # Связи с подключениями - referred = relationship( - "Connection", foreign_keys=[referred_tg_id], back_populates="referrals_received" - ) - referrer = relationship( - "Connection", foreign_keys=[referrer_tg_id], back_populates="referrals_sent" - ) diff --git a/requirements.txt b/requirements.txt index 262fc7fb..e3e0f098 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,4 +28,5 @@ yookassa==3.3.0 loguru aiocryptopay py3xui -sqlalchemy \ No newline at end of file +sqlalchemy +robokassa \ No newline at end of file diff --git a/scripts/node.sh b/scripts/node.sh deleted file mode 100644 index aee41eb3..00000000 --- a/scripts/node.sh +++ /dev/null @@ -1,164 +0,0 @@ -#!/bin/bash - -# Цвета для вывода -GREEN='\033[0;32m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -echo -e "${BLUE}3x-ui Installation Script by izzzzzi${NC}" -echo "----------------------------------------" - -# Запрос данных у пользователя -read -p "Enter your domain (e.g., example.com): " DOMAIN -read -p "Enter desired admin panel port (default: 2053): " PANEL_PORT -read -p "Enter desired sub panel port (default: 2054): " SUB_PANEL_PORT - -# Использование значения по умолчанию для порта, если не указано -PANEL_PORT=${PANEL_PORT:-2053} -SUB_PANEL_PORT=${SUB_PANEL_PORT:-2054} - -# Создание необходимых директорий -echo -e "${GREEN}Creating directories...${NC}" -mkdir -p nginx/conf.d db cert - -# Установка certbot -echo -e "${GREEN}Installing certbot...${NC}" -apt-get update -apt-get install certbot -y - -# Остановка nginx если он запущен -echo -e "${GREEN}Stopping nginx if running...${NC}" -docker-compose down 2>/dev/null -systemctl stop nginx 2>/dev/null - -# Получение сертификата -echo -e "${GREEN}Obtaining SSL certificate...${NC}" -certbot certonly --standalone --agree-tos --register-unsafely-without-email -d $DOMAIN - -# Проверка успешности получения сертификата -if [ ! -d "/etc/letsencrypt/live/$DOMAIN" ]; then - echo "Failed to obtain SSL certificate. Please check your domain settings." - exit 1 -fi - -# Тестирование автообновления -echo -e "${GREEN}Testing certificate renewal...${NC}" -certbot renew --dry-run - -# Создание docker-compose.yml -echo -e "${GREEN}Creating docker-compose.yml...${NC}" -cat > docker-compose.yml << EOL -version: "3" - -services: - nginx: - image: nginx:alpine - container_name: nginx - ports: - - "80:80" - - "443:443" - volumes: - - ./nginx/conf.d:/etc/nginx/conf.d - - /etc/letsencrypt:/etc/letsencrypt - - /var/lib/letsencrypt:/var/lib/letsencrypt - networks: - - proxy-network - restart: unless-stopped - depends_on: - - 3x-ui - - 3x-ui: - image: ghcr.io/mhsanaei/3x-ui:latest - container_name: 3x-ui - hostname: ${DOMAIN} - volumes: - - \$PWD/db/:/etc/x-ui/ - - \$PWD/cert/:/root/cert/ - - /etc/letsencrypt:/etc/letsencrypt:ro - environment: - XRAY_VMESS_AEAD_FORCED: "false" - tty: true - networks: - - proxy-network - restart: unless-stopped - -networks: - proxy-network: - driver: bridge -EOL - -# Создание конфигурации Nginx -echo -e "${GREEN}Creating Nginx configuration...${NC}" -cat > nginx/conf.d/default.conf << EOL -server { - listen 80; - server_name ${DOMAIN}; - - location / { - return 301 https://\$host\$request_uri; - } -} - -server { - listen 443 ssl; - server_name ${DOMAIN}; - - ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem; - - ssl_protocols TLSv1.2 TLSv1.3; - ssl_prefer_server_ciphers off; - ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; - - location / { - proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto \$scheme; - proxy_set_header Host \$http_host; - proxy_set_header X-Real-IP \$remote_addr; - proxy_set_header Range \$http_range; - proxy_set_header If-Range \$http_if_range; - proxy_redirect off; - proxy_pass http://3x-ui:${PANEL_PORT}; - } - - location /sub { - proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto \$scheme; - proxy_set_header Host \$http_host; - proxy_set_header X-Real-IP \$remote_addr; - proxy_set_header Range \$http_range; - proxy_set_header If-Range \$http_if_range; - proxy_redirect off; - proxy_pass http://3x-ui:${SUB_PANEL_PORT}; - } -} -EOL - -# Проверка наличия Docker и Docker Compose -if ! command -v docker &> /dev/null || ! command -v docker-compose &> /dev/null; then - echo -e "${GREEN}Installing Docker and Docker Compose...${NC}" - curl -fsSL https://get.docker.com | sh - curl -L "https://github.com/docker/compose/releases/download/v2.12.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose - chmod +x /usr/local/bin/docker-compose -fi - -# Настройка автообновления сертификатов -echo -e "${GREEN}Setting up certificate auto-renewal...${NC}" -cat > /etc/cron.d/certbot-renew << EOL -0 */12 * * * root certbot renew --quiet --deploy-hook "docker-compose -f $(pwd)/docker-compose.yml restart nginx" -EOL - -# Запуск сервисов -echo -e "${GREEN}Starting all services...${NC}" -docker-compose up -d - -echo -e "${GREEN}Installation completed!${NC}" -echo -e "${BLUE}You can access your 3x-ui panel at: https://${DOMAIN}${NC}" -echo -e "${YELLOW}Important: Default access to 3x-ui panel - ${NC}" -echo -e "${YELLOW}Login: admin${NC}" -echo -e "${YELLOW}Password: admin${NC}" -echo -e "${YELLOW}For security, it is recommended to change the password at: https://${DOMAIN}/panel/settings${NC}" -echo "Please wait a few minutes for all services to start properly." -echo "Default credentials can be found in the 3x-ui documentation." - -EOL \ No newline at end of file diff --git a/scripts/postgres.sh b/scripts/postgres.sh deleted file mode 100644 index ed77314b..00000000 --- a/scripts/postgres.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/bin/bash - -# Цвета для вывода -GREEN='\033[0;32m' -BLUE='\033[0;34m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -echo -e "${BLUE}PostgreSQL Installation and Setup Script by izzzzzi${NC}" -echo "----------------------------------------" - -# Запрос данных у пользователя -read -p "Enter PostgreSQL port (default: 5432): " DB_PORT -read -p "Enter database name: " DB_NAME -read -p "Enter database user: " DB_USER -read -s -p "Enter database password: " DB_PASS -echo -read -s -p "Confirm database password: " DB_PASS_CONFIRM -echo - -# Проверка паролей -if [ "$DB_PASS" != "$DB_PASS_CONFIRM" ]; then - echo -e "${RED}Passwords do not match!${NC}" - exit 1 -fi - -# Использование значения по умолчанию для порта -DB_PORT=${DB_PORT:-5432} - -# Установка PostgreSQL -echo -e "${GREEN}Installing PostgreSQL...${NC}" -apt-get update -apt-get install -y postgresql postgresql-contrib - -# Остановка PostgreSQL для изменения конфигурации -systemctl stop postgresql - -# Настройка PostgreSQL для внешних подключений -echo -e "${GREEN}Configuring PostgreSQL...${NC}" - -# Настройка postgresql.conf -PG_VERSION=$(ls /etc/postgresql/) -PG_CONF="/etc/postgresql/$PG_VERSION/main/postgresql.conf" -PG_HBA="/etc/postgresql/$PG_VERSION/main/pg_hba.conf" - -# Backup конфигурационных файлов -cp $PG_CONF "${PG_CONF}.backup" -cp $PG_HBA "${PG_HBA}.backup" - -# Изменение postgresql.conf -sed -i "s/#listen_addresses = 'localhost'/listen_addresses = '*'/" $PG_CONF -sed -i "s/#port = 5432/port = $DB_PORT/" $PG_CONF - -# Изменение pg_hba.conf -cat > $PG_HBA << EOL -# TYPE DATABASE USER ADDRESS METHOD -local all postgres peer -local all all peer -host all all 127.0.0.1/32 md5 -host all all ::1/128 md5 -host all all 0.0.0.0/0 md5 -EOL - -# Запуск PostgreSQL -systemctl start postgresql -systemctl enable postgresql - -# Создание пользователя и базы данных -echo -e "${GREEN}Creating database and user...${NC}" -sudo -u postgres psql << EOF -CREATE USER $DB_USER WITH PASSWORD '$DB_PASS'; -CREATE DATABASE $DB_NAME OWNER $DB_USER; -ALTER USER $DB_USER WITH SUPERUSER; -EOF - -# Настройка файрвола -echo -e "${GREEN}Configuring firewall...${NC}" -if command -v ufw >/dev/null; then - ufw allow $DB_PORT/tcp - ufw status -fi - -# Проверка статуса PostgreSQL -systemctl status postgresql --no-pager - -echo -e "${GREEN}Installation completed!${NC}" -echo -e "${BLUE}PostgreSQL is running on port: ${DB_PORT}${NC}" -echo -e "${BLUE}Database name: ${DB_NAME}${NC}" -echo -e "${BLUE}Database user: ${DB_USER}${NC}" -echo "You can now connect to your database using:" -echo "psql -h localhost -p $DB_PORT -U $DB_USER -d $DB_NAME" -echo -echo "For remote connections use:" -echo "psql -h YOUR_SERVER_IP -p $DB_PORT -U $DB_USER -d $DB_NAME" -echo -echo -e "${RED}Important: Make sure to save these credentials in a secure place!${NC}" - -# Проверка подключения -echo -e "${GREEN}Testing connection...${NC}" -PGPASSWORD=$DB_PASS psql -h localhost -p $DB_PORT -U $DB_USER -d $DB_NAME -c "\conninfo" - -# Добавление информации о создании бэкапов -echo -echo "To create a backup, use:" -echo "pg_dump -h localhost -p $DB_PORT -U $DB_USER -d $DB_NAME > backup.sql" -echo -echo "To restore from backup, use:" -echo "psql -h localhost -p $DB_PORT -U $DB_USER -d $DB_NAME < backup.sql" \ No newline at end of file