diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 00000000..410b1bac --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,35 @@ +name: Code Formatting + +on: [push, pull_request] + +jobs: + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff + + - name: Run Ruff formatter with pyproject.toml + run: ruff format . --config pyproject.toml --exclude main.py,handlers/payments + + - name: Check for changes + id: check_changes + run: | + git diff --exit-code || echo "changes_detected=true" >> $GITHUB_OUTPUT + + - name: Commit and push changes + if: steps.check_changes.outputs.changes_detected == 'true' + run: | + git config --global user.name 'GitHub Actions' + git config --global user.email 'actions@github.com' + git add . + git commit -m "Auto-format code with Ruff using pyproject.toml" + git push \ No newline at end of file diff --git a/.gitignore b/.gitignore index fcb54e4b..148fafa2 100644 --- a/.gitignore +++ b/.gitignore @@ -54,5 +54,6 @@ scripts models.py Dockerfile .csv - -setup.py \ No newline at end of file +/logs +setup.py +.ruff_cache \ No newline at end of file diff --git a/Makefile b/Makefile index aef0b00f..d949db7d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ formatting: - @echo "Running Ruff..." && ruff check . --fix - @echo "Running Ruff format..." && ruff format . + @echo "Running Ruff format..." && ruff format . --config pyproject.toml --exclude main.py,handlers/payments + + @echo "Running Ruff..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix lint: - @echo "Running Ruff checks..." && ruff check . \ No newline at end of file + @echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments \ No newline at end of file diff --git a/backup.py b/backup.py index 10135d71..8550e619 100644 --- a/backup.py +++ b/backup.py @@ -29,8 +29,12 @@ async def backup_database() -> Exception | None: return e -def _create_database_backup() -> (str | None, Exception | None): +def _create_database_backup() -> tuple[str | None, Exception | None]: date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S") + + if not os.path.exists(BACK_DIR): + os.makedirs(BACK_DIR) + filename = os.path.join(BACK_DIR, f"{DB_NAME}-backup-{date_formatted}.sql") try: @@ -93,18 +97,16 @@ async def create_backup_and_send_to_admins(xui) -> None: async def _send_backup_to_admins(backup_file_path: str) -> None: try: + import aiofiles + 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) - ) + + async with aiofiles.open(backup_file_path, "rb") as backup_file: + backup_data = await backup_file.read() + backup_input_file = BufferedInputFile(file=backup_data, 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 - ) + 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 947f5b42..027d2604 100644 --- a/bot.py +++ b/bot.py @@ -3,16 +3,16 @@ import traceback from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.filters import ExceptionTypeFilter from aiogram.fsm.storage.memory import MemoryStorage -from aiogram.types import ErrorEvent +from aiogram.types import BufferedInputFile, ErrorEvent +from aiogram.utils.markdown import hbold -from config import API_TOKEN +from config import ADMIN_ID, API_TOKEN +from filters.private import IsPrivateFilter from logger import logger -from middlewares.admin import AdminMiddleware -from middlewares.database import DatabaseMiddleware -from middlewares.delete import DeleteMessageMiddleware -from middlewares.logging import LoggingMiddleware -from middlewares.user import UserMiddleware +from middlewares import register_middleware bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) storage = MemoryStorage() @@ -20,30 +20,35 @@ dp = Dispatcher(bot=bot, storage=storage) version = "4.0.0-preAlpha(14-dev)" -dp.message.middleware(LoggingMiddleware()) -dp.callback_query.middleware(LoggingMiddleware()) +register_middleware(dp) -dp.message.middleware(AdminMiddleware()) -dp.callback_query.middleware(AdminMiddleware()) -dp.message.middleware(UserMiddleware()) -dp.callback_query.middleware(UserMiddleware()) - -dp.message.middleware(DatabaseMiddleware()) -dp.callback_query.middleware(DatabaseMiddleware()) - -# dp.message.middleware(ThrottlingMiddleware(limit=1)) -# dp.callback_query.middleware(ThrottlingMiddleware(limit=1)) - -dp.message.outer_middleware(DeleteMessageMiddleware()) -dp.callback_query.outer_middleware(DeleteMessageMiddleware()) +dp.message.filter(IsPrivateFilter()) +dp.callback_query.filter(IsPrivateFilter()) -@dp.error() -async def error_handler(event: ErrorEvent): - logger.error( - "Ошибка в боте:\n" - f"Исключение: {event.exception}\n" - f"Тип: {type(event.exception)}\n" - f"Update: {event.update}\n" - f"Трассировка:\n{traceback.format_exc()}" - ) +@dp.errors(ExceptionTypeFilter(Exception)) +async def errors_handler( + event: ErrorEvent, + bot: Bot, +) -> bool: + if isinstance(event.exception, TelegramForbiddenError): + logger.info(f"User {event.update.message.from_user.id} blocked the bot.") + return True + logger.exception(f"Update: {event.update}\nException: {event.exception}") + if not ADMIN_ID: + return True + try: + for admin_id in ADMIN_ID: + await bot.send_document( + chat_id=admin_id, + document=BufferedInputFile( + traceback.format_exc().encode(), + filename=f"error_{event.update.update_id}.txt", + ), + caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...", + ) + except TelegramBadRequest as exception: + logger.warning(f"Failed to send error details: {exception}") + except Exception as exception: + logger.error(f"Unexpected error in error handler: {exception}") + return True diff --git a/client.py b/client.py index 10953586..219ccca5 100644 --- a/client.py +++ b/client.py @@ -1,80 +1,101 @@ +from dataclasses import dataclass +from typing import Any + import py3xui from config import LIMIT_IP, SUPERNODE from logger import logger -async def add_client( - xui, - client_id: str, - email: str, - tg_id: str, - limit_ip: int, - total_gb: int, - expiry_time: int, - enable: bool, - flow: str, - inbound_id: int, - sub_id -): +@dataclass +class ClientConfig: + """Конфигурация клиента для добавления/обновления.""" + + client_id: str + email: str + tg_id: str + limit_ip: int + total_gb: int + expiry_time: int + enable: bool + flow: str + inbound_id: int + sub_id: str + + +async def add_client(xui: py3xui.AsyncApi, config: ClientConfig) -> dict[str, Any]: """ - Adds a client to the server via 3x-ui. + Добавляет клиента на сервер через 3x-ui. + + Args: + xui: Экземпляр API клиента + config: Конфигурация клиента + + Returns: + Dict[str, Any]: Результат операции в формате + {'status': 'success'|'failed'|'duplicate', 'error': str, 'email': str} """ try: await xui.login() client = py3xui.Client( - id=client_id, - email=email.lower(), - limit_ip=limit_ip, - total_gb=total_gb, - expiry_time=expiry_time, - enable=enable, - tg_id=tg_id, - sub_id=sub_id, - flow=flow, + id=config.client_id, + email=config.email.lower(), + limit_ip=config.limit_ip, + total_gb=config.total_gb, + expiry_time=config.expiry_time, + enable=config.enable, + tg_id=config.tg_id, + sub_id=config.sub_id, + flow=config.flow, ) - response = await xui.client.add(inbound_id, [client]) - - logger.info(f"Клиент {email} успешно добавлен с ID {client_id}.") + response = await xui.client.add(config.inbound_id, [client]) + logger.info(f"Клиент {config.email} успешно добавлен с ID {config.client_id}") return response if response else {"status": "failed"} except Exception as e: error_message = str(e) - if "Duplicate email" in error_message: - logger.warning( - f"Дублированный email: {email}. Пропуск. Сообщение: {error_message}" - ) - return {"status": "duplicate", "email": email} + logger.warning(f"Дублированный email: {config.email}. Пропуск. Сообщение: {error_message}") + return {"status": "duplicate", "email": config.email} - logger.error(f"Ошибка при добавлении клиента {email}: {error_message}") + logger.error(f"Ошибка при добавлении клиента {config.email}: {error_message}") return {"status": "failed", "error": error_message} async def extend_client_key( - xui, inbound_id, email: str, new_expiry_time: int, client_id: str, total_gb: int, sub_id = str -): + xui: py3xui.AsyncApi, inbound_id: int, email: str, new_expiry_time: int, client_id: str, total_gb: int, sub_id: str +) -> bool | None: """ - Функция для обновления срока действия ключа клиента по email. + Обновляет срок действия ключа клиента. + + Args: + xui: Экземпляр API клиента + inbound_id: ID входящего соединения + email: Email клиента + new_expiry_time: Новое время истечения + client_id: ID клиента + total_gb: Общий объем трафика + sub_id: ID подписки + + Returns: + Optional[bool]: True если успешно, False если ошибка, None если клиент не найден """ - await xui.login() try: + await xui.login() client = await xui.client.get_by_email(email) if not client: - logger.warning(f"Клиент с email {email} не найден.") - return + logger.warning(f"Клиент с email {email} не найден") + return None if not client.id: - logger.warning(f"Ошибка: клиент {email} не имеет действительного ID.") - return + logger.warning(f"Ошибка: клиент {email} не имеет действительного ID") + return None - logger.info( - f"Обновление ключа клиента {client.email} с ID {client.id} до нового времени: {new_expiry_time}" - ) + logger.info(f"Обновление ключа клиента {email} с ID {client.id} до {new_expiry_time}") client.id = client_id client.expiry_time = new_expiry_time @@ -87,41 +108,48 @@ async def extend_client_key( await xui.client.update(client.id, client) await xui.client.reset_stats(inbound_id, email) - logger.info( - f"Ключ клиента {client.email} успешно продлён до {new_expiry_time}." - ) + logger.info(f"Ключ клиента {email} успешно продлён до {new_expiry_time}") + return True except Exception as e: logger.error(f"Ошибка при обновлении клиента с email {email}: {e}") + return False async def delete_client( - xui, + xui: py3xui.AsyncApi, inbound_id: int, email: str, client_id: str, ) -> bool: """ - Функция для удаления клиента с сервера 3x-ui. - Возвращает True при успешном удалении, иначе False. + Удаляет клиента с сервера 3x-ui. + + Args: + xui: Экземпляр API клиента + inbound_id: ID входящего соединения + email: Email клиента + client_id: ID клиента + + Returns: + bool: True если удаление успешно, False в противном случае """ - await xui.login() try: + await xui.login() + if SUPERNODE: await xui.client.delete(inbound_id, client_id) - logger.info(f"Клиент с ID {client_id} был удален успешно (SUPERNODE).") + logger.info(f"Клиент с ID {client_id} был удален успешно (SUPERNODE)") return True client = await xui.client.get_by_email(email) - if not client: - logger.warning(f"Клиент с email {email} и ID {client_id} не найден.") + logger.warning(f"Клиент с email {email} и ID {client_id} не найден") return False client.id = client_id - await xui.client.delete(inbound_id, client.id) - logger.info(f"Клиент с ID {client_id} был удален успешно.") + logger.info(f"Клиент с ID {client_id} был удален успешно") return True except Exception as e: diff --git a/database.py b/database.py index cbd7cec2..ea1b5cab 100644 --- a/database.py +++ b/database.py @@ -3,12 +3,13 @@ from datetime import datetime from typing import Any import asyncpg +import pytz from config import DATABASE_URL, REFERRAL_BONUS_PERCENTAGES from logger import logger -async def save_temporary_data(session, tg_id: int, state: str, data: dict): +async def create_temporary_data(session, tg_id: int, state: str, data: dict): """Сохраняет временные данные пользователя.""" await session.execute( """ @@ -26,27 +27,39 @@ async def save_temporary_data(session, tg_id: int, state: str, data: dict): async def get_temporary_data(session, tg_id: int) -> dict | None: """Извлекает временные данные пользователя.""" - result = await session.fetchrow( - "SELECT state, data FROM temporary_data WHERE tg_id = $1", tg_id - ) + result = await session.fetchrow("SELECT state, data FROM temporary_data WHERE tg_id = $1", tg_id) if result: return {"state": result["state"], "data": json.loads(result["data"])} return None async def clear_temporary_data(session, tg_id: int): + # TODO rename delete_temporary_data await session.execute("DELETE FROM temporary_data WHERE tg_id = $1", tg_id) -async def add_blocked_user(tg_id: int, conn: asyncpg.Connection): +async def create_blocked_user(tg_id: int, conn: asyncpg.Connection): await conn.execute( "INSERT INTO blocked_users (tg_id) VALUES ($1) ON CONFLICT (tg_id) DO NOTHING", tg_id, ) +async def delete_blocked_user(tg_id: int | list[int], conn: asyncpg.Connection): + """ + Удаляет пользователя или список пользователей из списка заблокированных. + + :param tg_id: ID пользователя Telegram или список ID + :param conn: Подключение к базе данных + """ + if isinstance(tg_id, list): + await conn.execute("DELETE FROM blocked_users WHERE tg_id = ANY($1)", tg_id) + else: + await conn.execute("DELETE FROM blocked_users WHERE tg_id = $1", tg_id) + + async def init_db(file_path: str = "assets/schema.sql"): - with open(file_path, mode="r") as file: + with open(file_path) as file: sql_content = file.read() statements = [stmt.strip() for stmt in sql_content.split(";") if stmt.strip()] @@ -62,27 +75,57 @@ async def init_db(file_path: str = "assets/schema.sql"): await conn.close() -async def check_unique_server_name(server_name: str) -> bool: +async def check_unique_server_name(server_name: str, session: Any, cluster_name: str | None = None) -> bool: """ Проверяет уникальность имени сервера. :param server_name: Имя сервера. + :param session: Сессия базы данных. + :param cluster_name: Имя кластера (опционально). :return: True, если имя сервера уникально, False, если уже существует. """ - conn = await asyncpg.connect(DATABASE_URL) - - result = await conn.fetchrow( - "SELECT 1 FROM servers WHERE server_name = $1 LIMIT 1", server_name - ) - - await conn.close() + if cluster_name: + result = await session.fetchrow( + "SELECT 1 FROM servers WHERE server_name = $1 AND cluster_name = $2 LIMIT 1", server_name, cluster_name + ) + else: + result = await session.fetchrow("SELECT 1 FROM servers WHERE server_name = $1 LIMIT 1", server_name) return result is None -async def create_coupon( - coupon_code: str, amount: float, usage_limit: int, session: Any -): +async def check_server_name_by_cluster(server_name: str, session: Any) -> dict | None: + """ + Проверяет принадлежность сервера к кластеру. + + Args: + server_name (str): Имя сервера для проверки + session (Any): Сессия базы данных + + Returns: + dict | None: Словарь с информацией о кластере или None, если сервер не найден + - cluster_name (str): Название кластера + """ + try: + cluster_info = await session.fetchrow( + """ + SELECT cluster_name + FROM servers + WHERE server_name = $1 + """, + server_name, + ) + if cluster_info: + logger.info(f"Найден кластер для сервера {server_name}") + return dict(cluster_info) + logger.info(f"Кластер для сервера {server_name} не найден") + return None + except Exception as e: + logger.error(f"Ошибка при поиске кластера для сервера {server_name}: {e}") + raise + + +async def create_coupon(coupon_code: str, amount: float, usage_limit: int, session: Any): """ Создает новый купон в базе данных. @@ -114,38 +157,85 @@ async def create_coupon( raise -async def get_all_coupons(session: Any): +async def get_coupon_by_code(coupon_code: str, session: Any) -> dict | None: """ - Получает список всех купонов из базы данных. + Получает информацию о купоне по его коду. + + Args: + coupon_code (str): Код купона для поиска + session (Any): Сессия базы данных Returns: - list: Список словарей с информацией о купонах, каждый словарь содержит: - - code (str): Код купона - - amount (int): Сумма купона - - usage_limit (int): Максимальное количество использований - - usage_count (int): Текущее количество использований купона + dict | None: Словарь с информацией о купоне или None, если купон не найден + - id (int): ID купона + - usage_limit (int): Лимит использований + - usage_count (int): Текущее количество использований + - is_used (bool): Флаг использования + - amount (float): Сумма купона + + Raises: + Exception: В случае ошибки при выполнении запроса + """ + try: + result = await session.fetchrow( + """ + SELECT id, usage_limit, usage_count, is_used, amount + FROM coupons + WHERE code = $1 AND (usage_count < usage_limit OR usage_limit = 0) AND is_used = FALSE + """, + coupon_code, + ) + return dict(result) if result else None + except Exception as e: + logger.error(f"Ошибка при получении купона {coupon_code}: {e}") + raise + + +async def get_all_coupons(session: Any, page: int = 1, per_page: int = 10): + """ + Получает список купонов из базы данных с пагинацией. + + Args: + session (Any): Сессия базы данных для выполнения запроса + page (int): Номер страницы (по умолчанию 1) + per_page (int): Количество купонов на странице (по умолчанию 10) + + Returns: + dict: Словарь с информацией о купонах и пагинации: + - coupons (list): Список словарей с информацией о купонах + - total (int): Общее количество купонов + - pages (int): Общее количество страниц + - current_page (int): Текущая страница Raises: Exception: В случае ошибки при получении данных из базы """ try: + offset = (page - 1) * per_page coupons = await session.fetch( """ SELECT code, amount, usage_limit, usage_count FROM coupons - """ + ORDER BY id + LIMIT $1 OFFSET $2 + """, + per_page, + offset, ) - logger.info(f"Успешно получено {len(coupons)} купонов из базы данных") + total_count = await session.fetchval("SELECT COUNT(*) FROM coupons") + total_pages = -(-total_count // per_page) # Округление вверх - return coupons + logger.info(f"Успешно получено {len(coupons)} купонов из базы данных (страница {page})") + + return {"coupons": coupons, "total": total_count, "pages": total_pages, "current_page": page} except Exception as e: logger.error(f"Критическая ошибка при получении списка купонов: {e}") logger.exception("Трассировка стека ошибки получения купонов") - return [] + return {"coupons": [], "total": 0, "pages": 0, "current_page": page} -async def delete_coupon_from_db(coupon_code: str, session: Any): +async def delete_coupon(coupon_code: str, session: Any): """ Удаляет купон из базы данных по его коду. @@ -160,7 +250,7 @@ async def delete_coupon_from_db(coupon_code: str, session: Any): Exception: В случае ошибки при выполнении запроса к базе данных Example: - result = await delete_coupon_from_db('SALE50', session) + result = await delete_coupon('SALE50', session) """ try: coupon_record = await session.fetchrow( @@ -189,67 +279,38 @@ async def delete_coupon_from_db(coupon_code: str, session: Any): return False -async def restore_trial(tg_id: int, session: Any): +async def update_trial(tg_id: int, status: int, session: Any): """ - Восстанавливает возможность использования триального периода для пользователя. + Устанавливает статус триального периода для пользователя. Args: tg_id (int): Telegram ID пользователя + status (int): Статус триального периода (0 - доступен, 1 - использован) session (Any): Сессия базы данных Returns: - bool: True, если триал успешно восстановлен, False в случае ошибки + bool: True, если статус успешно установлен, False в случае ошибки """ try: await session.execute( """ INSERT INTO connections (tg_id, trial) - VALUES ($1, 0) + VALUES ($1, $2) ON CONFLICT (tg_id) - DO UPDATE SET trial = 0 + DO UPDATE SET trial = $2 """, tg_id, + status, ) - logger.info(f"Триальный период успешно восстановлен для пользователя {tg_id}") + status_text = "восстановлен" if status == 0 else "использован" + logger.info(f"Триальный период успешно {status_text} для пользователя {tg_id}") return True except Exception as e: - logger.error( - f"Ошибка при восстановлении триального периода для пользователя {tg_id}: {e}" - ) + logger.error(f"Ошибка при установке статуса триального периода для пользователя {tg_id}: {e}") return False -async def use_trial(tg_id: int, session: Any): - """ - Отмечает использование триального периода для пользователя. - - Args: - tg_id (int): Telegram ID пользователя - session (Any): Сессия базы данных - - Returns: - bool: True, если триал успешно использован, False в случае ошибки - """ - try: - await session.execute( - """ - INSERT INTO connections (tg_id, trial) - VALUES ($1, 1) - ON CONFLICT (tg_id) - DO UPDATE SET trial = 1 - """, - tg_id, - ) - logger.info(f"Триальный период успешно использован для пользователя {tg_id}") - return True - except Exception as e: - logger.error(f"Ошибка при использовании триала для пользователя {tg_id}: {e}") - return False - - -async def add_connection( - tg_id: int, balance: float = 0.0, trial: int = 0, session: Any = None -): +async def add_connection(tg_id: int, balance: float = 0.0, trial: int = 0, session: Any = None): """ Добавляет новое подключение для пользователя в базу данных. @@ -276,9 +337,7 @@ async def add_connection( f"Успешно добавлено новое подключение для пользователя {tg_id} с балансом {balance} и статусом триала {trial}" ) except Exception as e: - logger.error( - f"Не удалось добавить подключение для пользователя {tg_id}. Причина: {e}" - ) + logger.error(f"Не удалось добавить подключение для пользователя {tg_id}. Причина: {e}") raise @@ -352,15 +411,13 @@ async def store_key( key, server_id, ) - logger.info( - f"Ключ успешно сохранен для пользователя {tg_id} на сервере {server_id}" - ) + logger.info(f"Ключ успешно сохранен для пользователя {tg_id} на сервере {server_id}") except Exception as e: logger.error(f"Ошибка при сохранении ключа для пользователя {tg_id}: {e}") raise -async def get_keys(tg_id: int): +async def get_keys(tg_id: int, session: Any): """ Получает список ключей для указанного пользователя. @@ -373,10 +430,8 @@ async def get_keys(tg_id: int): Raises: Exception: В случае ошибки при подключении к базе данных или выполнении запроса """ - conn = None try: - conn = await asyncpg.connect(DATABASE_URL) - records = await conn.fetch( + records = await session.fetch( """ SELECT client_id, email, created_at, key FROM keys @@ -389,17 +444,14 @@ async def get_keys(tg_id: int): except Exception as e: logger.error(f"Ошибка при получении ключей для пользователя {tg_id}: {e}") raise - finally: - if conn: - await conn.close() -async def get_keys_by_server(tg_id: int, server_id: str): +async def get_keys_by_server(tg_id: int | None, server_id: str, session: Any): """ - Получает список ключей для указанного пользователя на определенном сервере. + Получает список ключей на определенном сервере. Если tg_id=None, возвращает все ключи на сервере. Args: - tg_id (int): Telegram ID пользователя + tg_id (int | None): Telegram ID пользователя или None для всех пользователей server_id (str): Идентификатор сервера Returns: @@ -408,61 +460,36 @@ async def get_keys_by_server(tg_id: int, server_id: str): Raises: Exception: В случае ошибки при подключении к базе данных или выполнении запроса """ - conn = None try: - conn = await asyncpg.connect(DATABASE_URL) - records = await conn.fetch( - """ - SELECT client_id, email, created_at, key - FROM keys - WHERE tg_id = $1 AND server_id = $2 - """, - tg_id, - server_id, - ) - logger.info( - f"Успешно получено {len(records)} ключей для пользователя {tg_id} на сервере {server_id}" - ) + if tg_id is not None: + records = await session.fetch( + """ + SELECT * + FROM keys + WHERE tg_id = $1 AND server_id = $2 + """, + tg_id, + server_id, + ) + logger.info(f"Успешно получено {len(records)} ключей для пользователя {tg_id} на сервере {server_id}") + else: + records = await session.fetch( + """ + SELECT * + FROM keys + WHERE server_id = $1 + """, + server_id, + ) + logger.info(f"Успешно получено {len(records)} ключей на сервере {server_id}") + return records except Exception as e: - logger.error( - f"Ошибка при получении ключей для пользователя {tg_id} на сервере {server_id}: {e}" - ) + error_msg = f"Ошибка при получении ключей на сервере {server_id}" + if tg_id is not None: + error_msg += f" для пользователя {tg_id}" + logger.error(f"{error_msg}: {e}") raise - finally: - if conn: - await conn.close() - - -async def has_active_key(tg_id: int) -> bool: - """ - Проверяет наличие активных ключей для указанного пользователя. - - Args: - tg_id (int): Telegram ID пользователя - - Returns: - bool: True, если у пользователя есть активные ключи, иначе False - - Raises: - Exception: В случае ошибки при подключении к базе данных или выполнении запроса - """ - conn = None - try: - conn = await asyncpg.connect(DATABASE_URL) - count = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE tg_id = $1", tg_id) - logger.info( - f"Проверка наличия ключей для пользователя {tg_id}. Найдено ключей: {count}" - ) - return count > 0 - except Exception as e: - logger.error( - f"Ошибка при проверке наличия ключей для пользователя {tg_id}: {e}" - ) - raise - finally: - if conn: - await conn.close() async def get_balance(tg_id: int) -> float: @@ -481,11 +508,9 @@ async def get_balance(tg_id: int) -> float: conn = None try: conn = await asyncpg.connect(DATABASE_URL) - balance = await conn.fetchval( - "SELECT balance FROM connections WHERE tg_id = $1", tg_id - ) + balance = await conn.fetchval("SELECT balance FROM connections WHERE tg_id = $1", tg_id) logger.info(f"Получен баланс для пользователя {tg_id}: {balance}") - return balance if balance is not None else 0.0 + return round(balance, 1) if balance is not None else 0.0 except Exception as e: logger.error(f"Ошибка при получении баланса для пользователя {tg_id}: {e}") return 0.0 @@ -494,21 +519,25 @@ async def get_balance(tg_id: int) -> float: await conn.close() -async def update_balance(tg_id: int, amount: float): +async def update_balance(tg_id: int, amount: float, session: Any = None): """ Обновляет баланс пользователя в базе данных. Args: tg_id (int): Telegram ID пользователя amount (float): Сумма для обновления баланса + session (Any, optional): Сессия базы данных. Если не передана, создается новая. Raises: Exception: В случае ошибки при подключении к базе данных или обновлении баланса """ conn = None try: - conn = await asyncpg.connect(DATABASE_URL) - await conn.execute( + if session is None: + conn = await asyncpg.connect(DATABASE_URL) + session = conn + + await session.execute( """ UPDATE connections SET balance = balance + $1 @@ -525,7 +554,7 @@ async def update_balance(tg_id: int, amount: float): logger.error(f"Ошибка при обновлении баланса для пользователя {tg_id}: {e}") raise finally: - if conn: + if conn is not None: await conn.close() @@ -541,15 +570,11 @@ async def get_trial(tg_id: int, session: Any) -> int: int: Статус триала (0 - не использован, 1 - использован) """ try: - trial = await session.fetchval( - "SELECT trial FROM connections WHERE tg_id = $1", tg_id - ) + trial = await session.fetchval("SELECT trial FROM connections WHERE tg_id = $1", tg_id) logger.info(f"Получен статус триала для пользователя {tg_id}: {trial}") return trial if trial is not None else 0 except Exception as e: - logger.error( - f"Ошибка при получении статуса триала для пользователя {tg_id}: {e}" - ) + logger.error(f"Ошибка при получении статуса триала для пользователя {tg_id}: {e}") return 0 @@ -573,44 +598,17 @@ async def get_key_count(tg_id: int) -> int: logger.info(f"Получено количество ключей для пользователя {tg_id}: {count}") return count if count is not None else 0 except Exception as e: - logger.error( - f"Ошибка при получении количества ключей для пользователя {tg_id}: {e}" - ) + logger.error(f"Ошибка при получении количества ключей для пользователя {tg_id}: {e}") return 0 finally: if conn: await conn.close() -async def get_all_users(conn): - """ - Получает список всех пользователей из базы данных. - - Args: - conn: Подключение к базе данных - - Returns: - list: Список Telegram ID всех пользователей - - Raises: - Exception: В случае ошибки при получении данных - """ - try: - users = await conn.fetch("SELECT tg_id FROM connections") - logger.info(f"Получен список всех пользователей. Количество: {len(users)}") - return users - except Exception as e: - logger.error(f"Ошибка при получении списка пользователей: {e}") - raise - - async def add_referral(referred_tg_id: int, referrer_tg_id: int, session: Any): try: - if referred_tg_id == referrer_tg_id: - logger.warning( - f"Пользователь {referred_tg_id} попытался использовать свою собственную реферальную ссылку." - ) + logger.warning(f"Пользователь {referred_tg_id} попытался использовать свою собственную реферальную ссылку.") return await session.execute( @@ -621,9 +619,7 @@ async def add_referral(referred_tg_id: int, referrer_tg_id: int, session: Any): referred_tg_id, referrer_tg_id, ) - logger.info( - f"Добавлена реферальная связь: приглашенный {referred_tg_id}, пригласивший {referrer_tg_id}" - ) + logger.info(f"Добавлена реферальная связь: приглашенный {referred_tg_id}, пригласивший {referrer_tg_id}") except Exception as e: logger.error(f"Ошибка при добавлении реферала: {e}") raise @@ -656,9 +652,7 @@ async def handle_referral_on_balance_update(tg_id: int, amount: float): for level in range(1, MAX_REFERRAL_LEVELS + 1): if current_tg_id in visited_tg_ids: - logger.warning( - f"Обнаружен цикл в реферальной цепочке для пользователя {current_tg_id}. Прекращение." - ) + logger.warning(f"Обнаружен цикл в реферальной цепочке для пользователя {current_tg_id}. Прекращение.") break visited_tg_ids.add(current_tg_id) @@ -697,15 +691,11 @@ async def handle_referral_on_balance_update(tg_id: int, amount: float): bonus = round(amount * bonus_percent, 2) if bonus > 0: - logger.info( - f"Начисление бонуса {bonus} рублей рефереру {referrer_tg_id} на уровне {level}." - ) + logger.info(f"Начисление бонуса {bonus} рублей рефереру {referrer_tg_id} на уровне {level}.") await update_balance(referrer_tg_id, bonus) except Exception as e: - logger.error( - f"Ошибка при обработке многоуровневой реферальной системы для {tg_id}: {e}" - ) + logger.error(f"Ошибка при обработке многоуровневой реферальной системы для {tg_id}: {e}") finally: if conn: await conn.close() @@ -804,9 +794,7 @@ async def get_referral_stats(referrer_tg_id: int): referrer_tg_id, ) - logger.debug( - f"Получена общая сумма бонусов от рефералов: {total_referral_bonus}" - ) + logger.debug(f"Получена общая сумма бонусов от рефералов: {total_referral_bonus}") return { "total_referrals": total_referrals, @@ -816,9 +804,7 @@ async def get_referral_stats(referrer_tg_id: int): } except Exception as e: - logger.error( - f"Ошибка при получении статистики рефералов для пользователя {referrer_tg_id}: {e}" - ) + logger.error(f"Ошибка при получении статистики рефералов для пользователя {referrer_tg_id}: {e}") raise finally: if conn: @@ -826,7 +812,7 @@ async def get_referral_stats(referrer_tg_id: int): logger.info("Закрытие подключения к базе данных") -async def update_key_expiry(client_id: str, new_expiry_time: int): +async def update_key_expiry(client_id: str, new_expiry_time: int, session: Any): """ Обновление времени истечения ключа для указанного клиента. @@ -837,14 +823,8 @@ async def update_key_expiry(client_id: str, new_expiry_time: int): Raises: Exception: В случае ошибки при подключении к базе данных или обновлении ключа """ - conn = None try: - conn = await asyncpg.connect(DATABASE_URL) - logger.info( - f"Установлено подключение к базе данных для обновления времени истечения ключа клиента {client_id}" - ) - - await conn.execute( + await session.execute( """ UPDATE keys SET expiry_time = $1, notified = FALSE, notified_24h = FALSE @@ -856,49 +836,8 @@ async def update_key_expiry(client_id: str, new_expiry_time: int): logger.info(f"Успешно обновлено время истечения ключа для клиента {client_id}") except Exception as e: - logger.error( - f"Ошибка при обновлении времени истечения ключа для клиента {client_id}: {e}" - ) + logger.error(f"Ошибка при обновлении времени истечения ключа для клиента {client_id}: {e}") raise - finally: - if conn: - await conn.close() - logger.info("Закрытие подключения к базе данных") - - -async def delete_key(client_id: str): - """ - Удаление ключа из базы данных для указанного клиента. - - Args: - client_id (str): Уникальный идентификатор клиента, ключ которого будет удален - - Raises: - Exception: В случае ошибки при подключении к базе данных или удалении ключа - """ - conn = None - try: - conn = await asyncpg.connect(DATABASE_URL) - logger.info( - f"Установлено подключение к базе данных для удаления ключа клиента {client_id}" - ) - - await conn.execute( - """ - DELETE FROM keys - WHERE client_id = $1 - """, - client_id, - ) - logger.info(f"Успешно удален ключ для клиента {client_id}") - - except Exception as e: - logger.error(f"Ошибка при удалении ключа для клиента {client_id}: {e}") - raise - finally: - if conn: - await conn.close() - logger.info("Закрытие подключения к базе данных") async def add_balance_to_client(client_id: str, amount: float): @@ -915,9 +854,7 @@ async def add_balance_to_client(client_id: str, amount: float): conn = None try: conn = await asyncpg.connect(DATABASE_URL) - logger.info( - f"Установлено подключение к базе данных для пополнения баланса клиента {client_id}" - ) + logger.info(f"Установлено подключение к базе данных для пополнения баланса клиента {client_id}") await conn.execute( """ @@ -955,9 +892,7 @@ async def get_client_id_by_email(email: str): conn = None try: conn = await asyncpg.connect(DATABASE_URL) - logger.info( - f"Установлено подключение к базе данных для поиска client_id по email: {email}" - ) + logger.info(f"Установлено подключение к базе данных для поиска client_id по email: {email}") client_id = await conn.fetchval( """ @@ -998,13 +933,9 @@ async def get_tg_id_by_client_id(client_id: str): conn = None try: conn = await asyncpg.connect(DATABASE_URL) - logger.info( - f"Установлено подключение к базе данных для поиска Telegram ID по client_id: {client_id}" - ) + logger.info(f"Установлено подключение к базе данных для поиска Telegram ID по client_id: {client_id}") - result = await conn.fetchrow( - "SELECT tg_id FROM keys WHERE client_id = $1", client_id - ) + result = await conn.fetchrow("SELECT tg_id FROM keys WHERE client_id = $1", client_id) if result: logger.info(f"Найден Telegram ID для client_id: {client_id}") @@ -1047,9 +978,7 @@ async def upsert_user( conn = None try: conn = await asyncpg.connect(DATABASE_URL) - logger.info( - f"Установлено подключение к базе данных для обновления пользователя {tg_id}" - ) + logger.info(f"Установлено подключение к базе данных для обновления пользователя {tg_id}") await conn.execute( """ @@ -1096,9 +1025,7 @@ async def add_payment(tg_id: int, amount: float, payment_system: str): conn = None try: conn = await asyncpg.connect(DATABASE_URL) - logger.info( - f"Установлено подключение к базе данных для добавления платежа пользователя {tg_id}" - ) + logger.info(f"Установлено подключение к базе данных для добавления платежа пользователя {tg_id}") await conn.execute( """ @@ -1109,9 +1036,7 @@ async def add_payment(tg_id: int, amount: float, payment_system: str): amount, payment_system, ) - logger.info( - f"Успешно добавлен платеж для пользователя {tg_id} на сумму {amount}" - ) + logger.info(f"Успешно добавлен платеж для пользователя {tg_id} на сумму {amount}") except Exception as e: logger.error(f"Ошибка при добавлении платежа для пользователя {tg_id}: {e}") raise @@ -1144,19 +1069,13 @@ async def add_notification(tg_id: int, notification_type: str, session: Any): tg_id, notification_type, ) - logger.info( - f"Успешно добавлено уведомление типа {notification_type} для пользователя {tg_id}" - ) + logger.info(f"Успешно добавлено уведомление типа {notification_type} для пользователя {tg_id}") except Exception as e: - logger.error( - f"Ошибка при добавлении notification для пользователя {tg_id}: {e}" - ) + logger.error(f"Ошибка при добавлении notification для пользователя {tg_id}: {e}") raise -async def check_notification_time( - tg_id: int, notification_type: str, hours: int = 12, session: Any = None -) -> bool: +async def check_notification_time(tg_id: int, notification_type: str, hours: int = 12, session: Any = None) -> bool: """ Проверяет, прошло ли указанное количество часов с момента последнего уведомления. @@ -1201,9 +1120,7 @@ async def check_notification_time( return can_notify except Exception as e: - logger.error( - f"Ошибка при проверке времени уведомления для пользователя {tg_id}: {e}" - ) + logger.error(f"Ошибка при проверке времени уведомления для пользователя {tg_id}: {e}") return False finally: @@ -1211,48 +1128,48 @@ async def check_notification_time( await conn.close() -async def get_servers_from_db(): - conn = await asyncpg.connect(DATABASE_URL) +async def get_servers(session: Any = None): + conn = None + try: + conn = session if session is not None else await asyncpg.connect(DATABASE_URL) - result = await conn.fetch( - """ - SELECT cluster_name, server_name, api_url, subscription_url, inbound_id - FROM servers - """ - ) - - await conn.close() - - servers = {} - for row in result: - cluster_name = row["cluster_name"] - if cluster_name not in servers: - servers[cluster_name] = [] - - servers[cluster_name].append( - { - "server_name": row["server_name"], - "api_url": row["api_url"], - "subscription_url": row["subscription_url"], - "inbound_id": row["inbound_id"], - } + result = await conn.fetch( + """ + SELECT cluster_name, server_name, api_url, subscription_url, inbound_id + FROM servers + """ ) + servers = {} + for row in result: + cluster_name = row["cluster_name"] + if cluster_name not in servers: + servers[cluster_name] = [] - return servers + servers[cluster_name].append( + { + "server_name": row["server_name"], + "api_url": row["api_url"], + "subscription_url": row["subscription_url"], + "inbound_id": row["inbound_id"], + } + ) + + return servers + + finally: + if conn is not None and session is None: + await conn.close() async def delete_user_data(session: Any, tg_id: int): - try: - await session.execute( - "DELETE FROM gifts WHERE sender_tg_id = $1 OR recipient_tg_id = $1", tg_id - ) + await session.execute("DELETE FROM gifts WHERE sender_tg_id = $1 OR recipient_tg_id = $1", tg_id) except Exception as e: logger.warning(f"У Вас версия без подарков для {tg_id}: {e}") await session.execute("DELETE FROM payments WHERE tg_id = $1", tg_id) await session.execute("DELETE FROM users WHERE tg_id = $1", tg_id) await session.execute("DELETE FROM connections WHERE tg_id = $1", tg_id) - await session.execute("DELETE FROM keys WHERE tg_id = $1", tg_id) + await delete_key(tg_id, session) await session.execute("DELETE FROM referrals WHERE referrer_tg_id = $1", tg_id) @@ -1305,10 +1222,349 @@ async def store_gift_link( logger.error(f"Не удалось добавить подарок с ID {gift_id} в базу данных.") return False except Exception as e: - logger.error(f"Ошибка при сохранении подарка с ID {gift_id} в базе данных: {e}") return False finally: if conn is not None and session is None: await conn.close() + + +async def get_key_details(email, session): + record = await session.fetchrow( + """ + SELECT k.key, k.email, k.expiry_time, k.server_id,k, k.client_id, k.created_at, 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"], + "created_at": record["created_at"], + "expiry_time": record["expiry_time"], + "client_id": record["client_id"], + "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"], + "email": record["email"], + } + + +async def delete_key(identifier, session): + """ + Удаляет ключ из базы данных по client_id или tg_id + + Args: + identifier (str): client_id или tg_id для удаления + session: Сессия базы данных + + Raises: + Exception: В случае ошибки при удалении ключа + """ + try: + # Проверяем, является ли идентификатор числом (tg_id) или строкой (client_id) + if identifier.isdigit(): + query = "DELETE FROM keys WHERE tg_id = $1" + else: + query = "DELETE FROM keys WHERE client_id = $1" + + await session.execute(query, identifier) + logger.info(f"Ключ с идентификатором {identifier} успешно удалён") + except Exception as e: + logger.error(f"Ошибка при удалении ключа с идентификатором {identifier} из базы данных: {e}") + + +async def create_server( + cluster_name: str, server_name: str, api_url: str, subscription_url: str, inbound_id: int, session: Any +): + """ + Добавляет новый сервер в базу данных. + + Args: + cluster_name (str): Название кластера + server_name (str): Название сервера + api_url (str): URL API сервера + subscription_url (str): URL подписки + inbound_id (int): ID входящего подключения + session (Any): Сессия базы данных + + Raises: + Exception: В случае ошибки при добавлении сервера + """ + try: + await session.execute( + """ + INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id) + VALUES ($1, $2, $3, $4, $5) + """, + cluster_name, + server_name, + api_url, + subscription_url, + inbound_id, + ) + logger.info(f"Сервер {server_name} успешно добавлен в кластер {cluster_name}") + except Exception as e: + logger.error(f"Ошибка при добавлении сервера {server_name} в кластер {cluster_name}: {e}") + raise + + +async def delete_server(server_name: str, session: Any): + """ + Удаляет сервер из базы данных по его названию. + + Args: + server_name (str): Название сервера для удаления + session (Any): Сессия базы данных + + Raises: + Exception: В случае ошибки при удалении сервера + """ + try: + await session.execute( + """ + DELETE FROM servers WHERE server_name = $1 + """, + server_name, + ) + logger.info(f"Сервер {server_name} успешно удалён из базы данных") + except Exception as e: + logger.error(f"Ошибка при удалении сервера {server_name} из базы данных: {e}") + raise + + +async def create_coupon_usage(coupon_id: int, user_id: int, session: Any): + """ + Создаёт запись об использовании купона в базе данных. + + Args: + coupon_id (int): ID купона + user_id (int): ID пользователя + session (Any): Сессия базы данных + + Raises: + Exception: В случае ошибки при создании записи + """ + try: + await session.execute( + """ + INSERT INTO coupon_usages (coupon_id, user_id, used_at) + VALUES ($1, $2, $3) + """, + coupon_id, + user_id, + datetime.utcnow(), + ) + logger.info(f"Создана запись об использовании купона {coupon_id} пользователем {user_id}") + except Exception as e: + logger.error(f"Ошибка при создании записи об использовании купона {coupon_id} пользователем {user_id}: {e}") + raise + + +async def check_coupon_usage(coupon_id: int, user_id: int, session: Any) -> bool: + """ + Проверяет, использовал ли пользователь данный купон. + + Args: + coupon_id (int): ID купона для проверки + user_id (int): ID пользователя для проверки + session (Any): Сессия базы данных + + Returns: + bool: True если купон уже использован, False если нет + + Raises: + Exception: В случае ошибки при выполнении запроса + """ + try: + result = await session.fetchrow( + """ + SELECT 1 FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2 + """, + coupon_id, + user_id, + ) + return result is not None + except Exception as e: + logger.error(f"Ошибка при проверке использования купона {coupon_id} пользователем {user_id}: {e}") + raise + + +async def update_coupon_usage_count(coupon_id: int, session: Any): + """ + Обновляет счетчик использования купона и его статус. + + Args: + coupon_id (int): ID купона для обновления + session (Any): Сессия базы данных + + Raises: + Exception: В случае ошибки при обновлении данных купона + """ + try: + await session.execute( + """ + UPDATE coupons + SET usage_count = usage_count + 1, + is_used = CASE WHEN usage_count + 1 >= usage_limit AND usage_limit > 0 THEN TRUE ELSE FALSE END + WHERE id = $1 + """, + coupon_id, + ) + logger.info(f"Успешно обновлен счетчик использования купона {coupon_id}") + except Exception as e: + logger.error(f"Ошибка при обновлении счетчика использования купона {coupon_id}: {e}") + raise + + +async def get_last_payments(tg_id: int, session: Any): + """ + Получает последние 3 платежа пользователя. + + Args: + tg_id (int): Telegram ID пользователя + session (Any): Сессия базы данных + + Returns: + list: Список последних платежей пользователя + + Raises: + Exception: В случае ошибки при выполнении запроса + """ + try: + records = await session.fetch( + """ + SELECT amount, payment_system, status, created_at + FROM payments + WHERE tg_id = $1 + ORDER BY created_at DESC + LIMIT 3 + """, + tg_id, + ) + logger.info(f"Успешно получены последние платежи для пользователя {tg_id}") + return records + except Exception as e: + logger.error(f"Ошибка при получении последних платежей для пользователя {tg_id}: {e}") + raise + + +async def get_coupon_details(coupon_id: str, session: Any): + """ + Получает детали купона по его ID. + + Args: + coupon_id (str): ID купона + session (Any): Сессия базы данных + + Returns: + dict: Словарь с деталями купона или None если купон не найден + + Raises: + Exception: В случае ошибки при выполнении запроса + """ + try: + record = await session.fetchrow( + """ + SELECT id, code, discount, usage_count, usage_limit, is_used + FROM coupons + WHERE id = $1 + """, + coupon_id, + ) + + if record: + logger.info(f"Успешно получены детали купона {coupon_id}") + return dict(record) + + logger.warning(f"Купон {coupon_id} не найден") + return None + + except Exception as e: + logger.error(f"Ошибка при получении деталей купона {coupon_id}: {e}") + raise + + +async def get_referral_by_referred_id(referred_tg_id: int, session: Any): + """ + Получает информацию о реферале по ID приглашенного пользователя. + + Args: + referred_tg_id (int): ID приглашенного пользователя + session (Any): Сессия базы данных + + Returns: + dict: Словарь с информацией о реферале или None если не найден + + Raises: + Exception: В случае ошибки при выполнении запроса + """ + try: + record = await session.fetchrow( + """ + SELECT * FROM referrals + WHERE referred_tg_id = $1 + """, + referred_tg_id, + ) + + if record: + logger.info(f"Успешно получена информация о реферале для пользователя {referred_tg_id}") + return dict(record) + + logger.info(f"Реферал для пользователя {referred_tg_id} не найден") + return None + + except Exception as e: + logger.error(f"Ошибка при получении информации о реферале для пользователя {referred_tg_id}: {e}") + raise + + +async def get_all_keys(session: Any = None): + """ + Получает все записи из таблицы keys. + + Args: + session (Any, optional): Сессия базы данных. По умолчанию None. + + Returns: + list: Список всех записей из таблицы keys + + Raises: + Exception: В случае ошибки при выполнении запроса + """ + conn = None + try: + conn = session if session is not None else await asyncpg.connect(DATABASE_URL) + keys = await conn.fetch("SELECT * FROM keys") + logger.info(f"Успешно получены все записи из таблицы keys. Количество: {len(keys)}") + return keys + except Exception as e: + logger.error(f"Ошибка при получении записей из таблицы keys: {e}") + raise + finally: + if conn is not None and session is None: + await conn.close() diff --git a/filters/admin.py b/filters/admin.py index 95328eaa..5a392217 100644 --- a/filters/admin.py +++ b/filters/admin.py @@ -10,6 +10,5 @@ class IsAdminFilter(BaseFilter): admin_ids: 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/filters/private.py b/filters/private.py new file mode 100644 index 00000000..6dea490e --- /dev/null +++ b/filters/private.py @@ -0,0 +1,8 @@ +from aiogram.enums import ChatType +from aiogram.filters import BaseFilter +from aiogram.types import Chat, TelegramObject + + +class IsPrivateFilter(BaseFilter): + async def __call__(self, event: TelegramObject, event_chat: Chat) -> bool: + return event_chat.type == ChatType.PRIVATE diff --git a/handlers/__init__.py b/handlers/__init__.py index f984092c..2ac2e777 100644 --- a/handlers/__init__.py +++ b/handlers/__init__.py @@ -3,6 +3,7 @@ __all__ = ("router",) from aiogram import Router from .admin import router as admin_router +from .captcha import router as captcha_router from .coupons import router as coupons_router from .donate import router as donate_router from .instructions import router as instructions_router @@ -12,11 +13,13 @@ from .pay import router as pay_router from .payments import router as payments_router from .profile import router as profile_router from .start import router as start_router +from .user import router as user_router router = Router(name="handlers_main_router") router.include_routers( start_router, + captcha_router, profile_router, pay_router, donate_router, @@ -26,4 +29,5 @@ router.include_routers( keys_router, instructions_router, admin_router, + user_router, ) diff --git a/handlers/admin/admin_backups.py b/handlers/admin/admin_backups.py index 51eee005..e838b891 100644 --- a/handlers/admin/admin_backups.py +++ b/handlers/admin/admin_backups.py @@ -12,14 +12,11 @@ router = Router() AdminPanelCallback.filter(F.action == "backups"), IsAdminFilter(), ) -async def handle_backups( - callback_query: CallbackQuery -): +async def handle_backups(callback_query: CallbackQuery): kb = build_admin_back_kb("management") await callback_query.message.edit_text( - text="💾 Инициализация резервного копирования базы данных...", - reply_markup=kb + text="💾 Инициализация резервного копирования базы данных...", reply_markup=kb ) exception = await backup_database() @@ -29,7 +26,4 @@ async def handle_backups( else: text = "✅ Резервная копия успешно создана и отправлена администраторам." - await callback_query.message.edit_text( - text=text, - reply_markup=kb - ) + 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 index a515a14e..833f3084 100644 --- a/handlers/admin/admin_bans.py +++ b/handlers/admin/admin_bans.py @@ -1,7 +1,7 @@ from typing import Any from aiogram import F, Router -from aiogram.types import CallbackQuery, BufferedInputFile +from aiogram.types import BufferedInputFile, CallbackQuery from database import delete_user_data from filters.admin import IsAdminFilter @@ -15,13 +15,8 @@ router = Router() AdminPanelCallback.filter(F.action == "bans"), IsAdminFilter(), ) -async def handle_bans( - callback_query: CallbackQuery -): - text = ( - "🚫 Заблокировавшие бота" - "\n\nЗдесь можно просматривать и удалять пользователей, которые забанили вашего бота!" - ) +async def handle_bans(callback_query: CallbackQuery): + text = "🚫 Заблокировавшие бота\n\nЗдесь можно просматривать и удалять пользователей, которые забанили вашего бота!" await callback_query.message.edit_text( text=text, @@ -33,10 +28,7 @@ async def handle_bans( AdminPanelCallback.filter(F.action == "bans_export"), IsAdminFilter(), ) -async def handle_bans_export( - callback_query: CallbackQuery, - session: Any -): +async def handle_bans_export(callback_query: CallbackQuery, session: Any): kb = build_admin_back_kb("management") try: @@ -53,9 +45,7 @@ async def handle_bans_export( csv_output.seek(0) - document = BufferedInputFile( - file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv" - ) + document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv") await callback_query.message.answer_document( document=document, @@ -72,10 +62,7 @@ async def handle_bans_export( AdminPanelCallback.filter(F.action == "bans_delete_banned"), IsAdminFilter(), ) -async def handle_bans_delete_banned( - callback_query: CallbackQuery, - session: Any -): +async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any): kb = build_admin_back_kb("bans") try: @@ -92,9 +79,7 @@ async def handle_bans_delete_banned( 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 session.execute("DELETE FROM blocked_users WHERE tg_id = ANY($1)", blocked_ids) await callback_query.message.answer( text=f"🗑️ Удалены данные о {len(blocked_ids)} пользователях и связанных записях.", diff --git a/handlers/admin/admin_coupons.py b/handlers/admin/admin_coupons.py index d1a34644..52c10a4e 100644 --- a/handlers/admin/admin_coupons.py +++ b/handlers/admin/admin_coupons.py @@ -4,9 +4,9 @@ from aiogram import F, Router, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup -from database import delete_coupon_from_db, get_all_coupons, create_coupon +from database import create_coupon, delete_coupon, get_all_coupons from filters.admin import IsAdminFilter -from keyboards.admin.coupons_kb import build_coupons_kb, build_coupons_list_kb, AdminCouponDeleteCallback +from keyboards.admin.coupons_kb import AdminCouponDeleteCallback, build_coupons_kb, build_coupons_list_kb from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb from logger import logger @@ -22,22 +22,16 @@ class AdminCouponsState(StatesGroup): IsAdminFilter(), ) async def handle_coupons( - callback_query: types.CallbackQuery, + callback_query: types.CallbackQuery, ): - await callback_query.message.edit_text( - text="🛠 Меню управления купонами:", - reply_markup=build_coupons_kb() - ) + await callback_query.message.edit_text(text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb()) @router.callback_query( AdminPanelCallback.filter(F.action == "coupons_create"), IsAdminFilter(), ) -async def handle_coupons_create( - callback_query: types.CallbackQuery, - state: FSMContext -): +async def handle_coupons_create(callback_query: types.CallbackQuery, state: FSMContext): text = ( "🎫 Введите данные для создания купона в формате:\n\n" "📝 код 💰 сумма 🔢 лимит\n\n" @@ -51,15 +45,8 @@ async def handle_coupons_create( 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 -): +@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() @@ -83,10 +70,7 @@ async def handle_coupon_data_input( coupon_amount = float(parts[1]) usage_limit = int(parts[2]) except ValueError: - text = ( - "⚠️ Проверьте правильность введенных данных!\n" - "💱 Сумма должна быть числом, а лимит — целым числом." - ) + text = "⚠️ Проверьте правильность введенных данных!\n💱 Сумма должна быть числом, а лимит — целым числом." await message.answer( text=text, @@ -103,10 +87,7 @@ async def handle_coupon_data_input( f"🔢 Лимит использования: {usage_limit} раз" ) - await message.answer( - text=text, - reply_markup=kb - ) + await message.answer(text=text, reply_markup=kb) await state.clear() except Exception as e: @@ -117,12 +98,14 @@ async def handle_coupon_data_input( AdminPanelCallback.filter(F.action == "coupons_list"), IsAdminFilter(), ) -async def handle_coupons_list( - callback_query: types.CallbackQuery, - session: Any -): +async def handle_coupons_list(callback_query: types.CallbackQuery, session: Any): try: - coupons = await get_all_coupons(session) + page = int(callback_query.data.split(":")[1]) if ":" in callback_query.data else 1 + per_page = 10 + result = await get_all_coupons(session, page, per_page) + coupons = result["coupons"] + total_pages = result["pages"] + current_page = result["current_page"] if not coupons: await callback_query.message.edit_text( @@ -142,13 +125,11 @@ async def handle_coupons_list( f"✅ Использовано: {coupon['usage_count']} раз\n\n" ) - await callback_query.message.edit_text( - text=coupon_list, - reply_markup=kb - ) + await callback_query.message.edit_text(text=coupon_list, reply_markup=kb) except Exception as e: logger.error(f"Ошибка при получении списка купонов: {e}") + await callback_query.message.answer("Произошла ошибка при получении списка купонов.") @router.callback_query( @@ -156,14 +137,12 @@ async def handle_coupons_list( IsAdminFilter(), ) async def handle_coupon_delete( - callback_query: types.CallbackQuery, - callback_data: AdminCouponDeleteCallback, - session: Any + 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) + result = await delete_coupon(coupon_code, session) if result: await handle_coupons_list(callback_query, session) diff --git a/handlers/admin/admin_panel.py b/handlers/admin/admin_panel.py index a5594caa..f3d76f87 100644 --- a/handlers/admin/admin_panel.py +++ b/handlers/admin/admin_panel.py @@ -5,57 +5,33 @@ from aiogram.types import CallbackQuery from bot import version from filters.admin import IsAdminFilter -from keyboards.admin.panel_kb import build_panel_kb, AdminPanelCallback, build_management_kb +from keyboards.admin.panel_kb import AdminPanelCallback, build_management_kb, build_panel_kb router = Router() -@router.callback_query( - AdminPanelCallback.filter(F.action == "admin"), - IsAdminFilter() -) +@router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter()) async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext): - text = ( - "🤖 Панель администратора" - f"\n📌 Версия бота: {version}" - ) + text = f"🤖 Панель администратора\n📌 Версия бота: {version}" await state.clear() - await callback_query.message.edit_text( - text=text, - reply_markup=build_panel_kb() - ) + await callback_query.message.edit_text(text=text, reply_markup=build_panel_kb()) -@router.callback_query( - F.data == "admin", - IsAdminFilter() -) +@router.callback_query(F.data == "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"), - IsAdminFilter() -) +@router.message(Command("admin"), IsAdminFilter()) async def handle_admin_message(message: types.Message, state: FSMContext): - text = ( - "🤖 Панель администратора" - f"\n📌 Версия бота: {version}" - ) + text = f"🤖 Панель администратора\n📌 Версия бота: {version}" await state.clear() - await message.answer( - text=text, - reply_markup=build_panel_kb() - ) + await message.answer(text=text, reply_markup=build_panel_kb()) -@router.callback_query( - AdminPanelCallback.filter(F.action == "management"), - IsAdminFilter() -) +@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter()) async def handle_management(callback_query: CallbackQuery): await callback_query.message.edit_text( text="🤖 Управление ботом", diff --git a/handlers/admin/admin_restart.py b/handlers/admin/admin_restart.py index 157bf13b..d2501758 100644 --- a/handlers/admin/admin_restart.py +++ b/handlers/admin/admin_restart.py @@ -4,7 +4,7 @@ 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 +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb, build_restart_kb router = Router() @@ -33,17 +33,8 @@ async def handle_restart_confirm(callback_query: CallbackQuery): capture_output=True, text=True, ) - await callback_query.message.edit_text( - text="🔄 Бот успешно перезагружен!", - reply_markup=kb - ) + await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb) except subprocess.CalledProcessError: - await callback_query.message.edit_text( - text="🔄 Бот успешно перезагружен!", - reply_markup=kb - ) + 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 - ) + 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 index e82598bd..4e8a92cf 100644 --- a/handlers/admin/admin_sender.py +++ b/handlers/admin/admin_sender.py @@ -8,7 +8,7 @@ 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 keyboards.admin.sender_kb import AdminSenderCallback, build_sender_kb from logger import logger router = Router() @@ -22,9 +22,7 @@ class AdminSender(StatesGroup): AdminPanelCallback.filter(F.action == "sender"), IsAdminFilter(), ) -async def handle_sender( - callback_query: CallbackQuery -): +async def handle_sender(callback_query: CallbackQuery): await callback_query.message.edit_text( text="✍️ Выберите группу пользователей для рассылки:", reply_markup=build_sender_kb(), @@ -35,11 +33,7 @@ async def handle_sender( AdminSenderCallback.filter(), IsAdminFilter(), ) -async def handle_sender_callback( - callback_query: CallbackQuery, - callback_data: AdminSenderCallback, - state: FSMContext -): +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"), @@ -52,11 +46,7 @@ async def handle_sender_callback( AdminSender.waiting_for_message, IsAdminFilter(), ) -async def handle_message_input( - message: types.Message, - state: FSMContext, - session: Any -): +async def handle_message_input(message: types.Message, state: FSMContext, session: Any): text_message = message.text try: @@ -64,20 +54,26 @@ async def handle_message_input( send_to = state_data.get("type", "all") if send_to == "subscribed": - tg_ids = await session.fetch(""" + 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)) + """, + int(datetime.utcnow().timestamp() * 1000), + ) elif send_to == "unsubscribed": - tg_ids = await session.fetch(""" + 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)) + """, + int(datetime.utcnow().timestamp() * 1000), + ) else: tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections") @@ -87,13 +83,10 @@ async def handle_message_input( for record in tg_ids: tg_id = record["tg_id"] try: - await message.bot.send_message( - chat_id=tg_id, - text=text_message - ) + await message.bot.send_message(chat_id=tg_id, text=text_message) success_count += 1 - except Exception: - pass + except Exception as e: + logger.error(e) text = ( f"📤 Рассылка завершена!" @@ -102,10 +95,7 @@ async def handle_message_input( f"\n❌ Не доставлено: {total_users - success_count}" ) - await message.answer( - text=text, - reply_markup=build_admin_back_kb("stats") - ) + await message.answer(text=text, reply_markup=build_admin_back_kb("stats")) except Exception as e: logger.error(f"❗ Ошибка при подключении к базе данных: {e}") diff --git a/handlers/admin/admin_servers.py b/handlers/admin/admin_servers.py index d190f1ee..9632b424 100644 --- a/handlers/admin/admin_servers.py +++ b/handlers/admin/admin_servers.py @@ -1,3 +1,5 @@ +from typing import Any + import asyncpg from aiogram import F, Router, types from aiogram.fsm.context import FSMContext @@ -6,12 +8,16 @@ 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 database import check_unique_server_name, delete_server, get_servers from filters.admin import IsAdminFilter 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 +from keyboards.admin.servers_kb import ( + AdminServerEditorCallback, + build_clusters_editor_kb, + build_delete_server_kb, + build_manage_cluster_kb, + build_manage_server_kb, +) router = Router() @@ -28,10 +34,8 @@ class AdminServersEditor(StatesGroup): AdminPanelCallback.filter(F.action == "servers"), IsAdminFilter(), ) -async def handle_servers( - callback_query: types.CallbackQuery -): - servers = await get_servers_from_db() +async def handle_servers(callback_query: types.CallbackQuery): + servers = await get_servers() text = ( "🔧 Управление кластерами\n\n" @@ -51,36 +55,23 @@ async def handle_servers( AdminPanelCallback.filter(F.action == "clusters_add"), IsAdminFilter(), ) -async def handle_clusters_add( - callback_query: types.CallbackQuery, - state: FSMContext -): +async def handle_clusters_add(callback_query: types.CallbackQuery, state: FSMContext): text = ( "🔧 Введите имя нового кластера:\n\n" "Имя кластера должно быть уникальным!\n" "Пример: cluster1 или us_east_1" ) - await callback_query.message.edit_text( - text=text, - reply_markup=build_admin_back_kb("servers") - ) + await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb("servers")) await state.set_state(AdminServersEditor.waiting_for_cluster_name) -@router.message( - AdminServersEditor.waiting_for_cluster_name, - IsAdminFilter() -) -async def handle_cluster_name_input( - message: types.Message, - state: FSMContext -): +@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( - text="❌ Имя кластера не может быть пустым. Попробуйте снова.", - reply_markup=build_admin_back_kb("servers") + text="❌ Имя кластера не может быть пустым. Попробуйте снова.", reply_markup=build_admin_back_kb("servers") ) return @@ -101,18 +92,11 @@ async def handle_cluster_name_input( await state.set_state(AdminServersEditor.waiting_for_server_name) -@router.message( - AdminServersEditor.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") + text="❌ Имя сервера не может быть пустым. Попробуйте снова.", reply_markup=build_admin_back_kb("servers") ) return @@ -121,7 +105,7 @@ async def handle_server_name_input( if not await check_unique_server_name(server_name): await message.answer( text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.", - reply_markup=build_admin_back_kb("servers") + reply_markup=build_admin_back_kb("servers"), ) return @@ -144,18 +128,12 @@ async def handle_server_name_input( await state.set_state(AdminServersEditor.waiting_for_api_url) -@router.message( - AdminServersEditor.waiting_for_api_url, - IsAdminFilter() -) -async def handle_api_url_input( - message: types.Message, - state: FSMContext -): +@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( text="❌ API URL должен начинаться с https://. Попробуйте снова.", - reply_markup=build_admin_back_kb("servers") + reply_markup=build_admin_back_kb("servers"), ) return @@ -182,18 +160,12 @@ async def handle_api_url_input( await state.set_state(AdminServersEditor.waiting_for_subscription_url) -@router.message( - AdminServersEditor.waiting_for_subscription_url, - IsAdminFilter() -) -async def handle_subscription_url_input( - message: types.Message, - state: FSMContext -): +@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( text="❌ subscription_url должен начинаться с https://. Попробуйте снова.", - reply_markup=build_admin_back_kb("servers") + reply_markup=build_admin_back_kb("servers"), ) return @@ -216,20 +188,14 @@ async def handle_subscription_url_input( await state.set_state(AdminServersEditor.waiting_for_inbound_id) -@router.message( - AdminServersEditor.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( text="❌ inbound_id должен быть числовым значением. Попробуйте снова.", - reply_markup=build_admin_back_kb("servers") + reply_markup=build_admin_back_kb("servers"), ) return @@ -261,17 +227,13 @@ async def handle_inbound_id_input( await state.clear() -@router.callback_query( - AdminServerEditorCallback.filter(F.action == "clusters_manage"), - IsAdminFilter() -) +@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_manage"), IsAdminFilter()) async def handle_clusters_manage( - callback_query: types.CallbackQuery, - callback_data: AdminServerEditorCallback, + callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any ): cluster_name = callback_data.data - servers = await get_servers_from_db() + servers = await get_servers(session) cluster_servers = servers.get(cluster_name, []) await callback_query.message.edit_text( @@ -280,23 +242,17 @@ async def handle_clusters_manage( ) -@router.callback_query( - AdminServerEditorCallback.filter(F.action == "servers_availability"), - IsAdminFilter() -) +@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_availability"), IsAdminFilter()) async def handle_servers_availability( - callback_query: types.CallbackQuery, - callback_data: AdminServerEditorCallback + callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any ): cluster_name = callback_data.data - servers = await get_servers_from_db() + servers = await get_servers(session) cluster_servers = servers.get(cluster_name, []) if not cluster_servers: - await callback_query.message.answer( - text=f"Кластер '{cluster_name}' не содержит серверов." - ) + await callback_query.message.answer(text=f"Кластер '{cluster_name}' не содержит серверов.") return text = ( @@ -304,51 +260,32 @@ async def handle_servers_availability( "Это может занять до 1 минуты, пожалуйста, подождите..." ) - in_progress_message = await callback_query.message.answer( - text=text - ) + in_progress_message = await callback_query.message.answer(text=text) - text = ( - f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n" - ) + text = f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n" for server in cluster_servers: - xui = AsyncApi( - server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD - ) + xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD) try: await xui.login() online_users = len(await xui.client.online()) - text += ( - f"🌍 {server['server_name']}: {online_users} активных пользователей.\n" - ) + text += f"🌍 {server['server_name']}: {online_users} активных пользователей.\n" except Exception as e: text += f"❌ {server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n" - await in_progress_message.edit_text( - text=text, - reply_markup=build_admin_back_kb("servers") - ) + await in_progress_message.edit_text(text=text, reply_markup=build_admin_back_kb("servers")) -@router.callback_query( - AdminServerEditorCallback.filter(F.action == "servers_manage"), - IsAdminFilter() -) -async def handle_servers_manage( - callback_query: types.CallbackQuery, - callback_data: AdminServerEditorCallback -): +@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() + servers = await get_servers() cluster_name, server = next( - ((c, s) for c, cs in servers.items() - for s in cs if s["server_name"] == server_name), - (None, None) + ((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None) ) if server: @@ -368,19 +305,11 @@ async def handle_servers_manage( reply_markup=build_manage_server_kb(server_name, cluster_name), ) else: - await callback_query.message.edit_text( - text="❌ Сервер не найден." - ) + await callback_query.message.edit_text(text="❌ Сервер не найден.") -@router.callback_query( - AdminServerEditorCallback.filter(F.action == "servers_delete"), - IsAdminFilter() -) -async def handle_servers_delete( - callback_query: types.CallbackQuery, - callback_data: AdminServerEditorCallback -): +@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_data.data await callback_query.message.edit_text( @@ -389,39 +318,22 @@ async def handle_servers_delete( ) -@router.callback_query( - AdminServerEditorCallback.filter(F.action == "servers_delete_confirm"), - IsAdminFilter() -) +@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_delete_confirm"), IsAdminFilter()) async def handle_servers_delete_confirm( - callback_query: types.CallbackQuery, - callback_data: AdminServerEditorCallback + callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any ): server_name = callback_data.data - conn = await asyncpg.connect(DATABASE_URL) - await conn.execute( - """ - DELETE FROM servers WHERE server_name = $1 - """, - server_name, - ) - await conn.close() + await delete_server(server_name, session) await callback_query.message.edit_text( - text=f"🗑️ Сервер {server_name} успешно удален.", - reply_markup=build_admin_back_kb("servers") + text=f"🗑️ Сервер {server_name} успешно удален.", reply_markup=build_admin_back_kb("servers") ) -@router.callback_query( - AdminServerEditorCallback.filter(F.action == "servers_add"), - IsAdminFilter() -) +@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_add"), IsAdminFilter()) async def handle_servers_add( - callback_query: types.CallbackQuery, - callback_data: AdminServerEditorCallback, - state: FSMContext + callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, state: FSMContext ): cluster_name = callback_data.data @@ -441,17 +353,13 @@ async def handle_servers_add( await state.set_state(AdminServersEditor.waiting_for_server_name) -@router.callback_query( - AdminServerEditorCallback.filter(F.action == "clusters_backup"), - IsAdminFilter() -) +@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_backup"), IsAdminFilter()) async def handle_clusters_backup( - callback_query: types.CallbackQuery, - callback_data: AdminServerEditorCallback, + callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any ): cluster_name = callback_data.data - servers = await get_servers_from_db() + servers = await get_servers(session) cluster_servers = servers.get(cluster_name, []) for server in cluster_servers: diff --git a/handlers/admin/admin_stats.py b/handlers/admin/admin_stats.py index 0a74d69f..48ad79bb 100644 --- a/handlers/admin/admin_stats.py +++ b/handlers/admin/admin_stats.py @@ -17,10 +17,7 @@ router = Router() AdminPanelCallback.filter(F.action == "stats"), IsAdminFilter(), ) -async def handle_stats( - callback_query: CallbackQuery, - session: Any -): +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") @@ -35,13 +32,9 @@ async def handle_stats( 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" - ) + 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_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)" ) @@ -49,9 +42,7 @@ async def handle_stats( "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" - ) + 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", @@ -81,10 +72,7 @@ async def handle_stats( f" 🏦 За все время: {total_payments_all_time} ₽\n" ) - await callback_query.message.edit_text( - text=stats_message, - reply_markup=build_stats_kb() - ) + 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}") @@ -93,46 +81,28 @@ async def handle_stats( AdminPanelCallback.filter(F.action == "stats_export_users_csv"), IsAdminFilter(), ) -async def handle_export_users_csv( - callback_query: CallbackQuery, - session: Any -): +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" - ) + 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 - ) + 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 -): +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" - ) + 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 - ) + 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 new file mode 100644 index 00000000..4d4ff573 --- /dev/null +++ b/handlers/admin/admin_user_editor.py @@ -0,0 +1,592 @@ +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_key, + delete_user_data, + get_client_id_by_email, + get_key_details, + get_keys, + get_servers, + update_key_expiry, + update_trial, +) +from filters.admin import IsAdminFilter +from handlers.keys.key_utils import ( + delete_key_from_cluster, + 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 get_keys(tg_id, session) + 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 get_keys(tg_id, session) + 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 update_trial(tg_id, 0, 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() + + +@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 get_key_details(email, session) + 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(session) + + 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, session) + + 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] + key_details = await get_key_details(email, session) + + if key_details 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|{key_details['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(session) + + 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(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 get_keys(tg_id, session) + 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(session) + 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 index 2520299a..8ad42bea 100644 --- a/handlers/admin/admin_users.py +++ b/handlers/admin/admin_users.py @@ -10,19 +10,29 @@ 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 database import delete_key, delete_user_data, get_client_id_by_email, get_servers, update_key_expiry, update_trial 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, + 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 keyboards.admin.users_kb import ( + AdminUserEditorCallback, + AdminUserKeyEditorCallback, + build_editor_kb, + build_key_delete_kb, + build_key_edit_kb, + build_user_delete_kb, + build_user_edit_kb, + build_user_key_kb, + build_users_balance_change_kb, + build_users_balance_kb, + build_users_key_expiry_kb, + build_users_key_show_kb, +) from logger import logger router = Router() @@ -42,10 +52,7 @@ class UserEditorState(StatesGroup): AdminPanelCallback.filter(F.action == "search_user"), IsAdminFilter(), ) -async def handle_search_user( - callback_query: CallbackQuery, - state: FSMContext -): +async def handle_search_user(callback_query: CallbackQuery, state: FSMContext): text = ( "🔍 Поиск пользователя" "\n\n📌 Введите ID, Username или перешлите сообщение пользователя." @@ -55,36 +62,20 @@ async def handle_search_user( ) await state.set_state(UserEditorState.waiting_for_user_data) - await callback_query.message.edit_text( - text=text, - reply_markup=build_admin_back_kb() - ) + 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 -): +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() - ) + 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 -): +@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: @@ -93,23 +84,18 @@ async def handle_user_data_input( return if not message.text: - await message.answer( - text="🚫 Пожалуйста, отправьте текстовое сообщение.", - reply_markup=kb - ) + await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb) return if message.text.isdigit(): tg_id = int(message.text) else: # Удаление '@' символа в начале сообщения - username = message.text.strip().lstrip('@') + username = message.text.strip().lstrip("@") # Удаление начала ссылки на профиль - username = username.replace('https://t.me/', '') + username = username.replace("https://t.me/", "") - user = await session.fetchrow( - "SELECT tg_id FROM users WHERE username = $1", username - ) + user = await session.fetchrow("SELECT tg_id FROM users WHERE username = $1", username) if not user: await message.answer( @@ -123,32 +109,19 @@ async def handle_user_data_input( 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 -): +@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 - ) + 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 - ) + await message.answer(text="🚫 Пользователь с указанным именем ключа не найден.", reply_markup=kb) return await process_user_search(message, state, session, key_details["tg_id"]) @@ -159,46 +132,28 @@ async def handle_key_name_input( IsAdminFilter(), ) async def handle_send_message( - callback_query: types.CallbackQuery, - callback_data: AdminUserEditorCallback, - state: FSMContext + 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) + 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 -): +@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) - ) + 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 message.answer(text=f"❌ Не удалось отправить сообщение: {e}", reply_markup=build_editor_kb(tg_id)) await state.clear() @@ -208,37 +163,28 @@ async def handle_message_text_input( IsAdminFilter(), ) async def handle_trial_restore( - callback_query: types.CallbackQuery, - callback_data: AdminUserEditorCallback, - session: Any + 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) - ) + await update_trial(tg_id, 0, 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 -): +@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(""" + 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) + """, + tg_id, + ) balance = await get_user_balance(tg_id, session) @@ -263,21 +209,12 @@ async def handle_balance_change( else: text += "\n 🚫 Отсутствуют" - await callback_query.message.edit_text( - text=text, - reply_markup=build_users_balance_kb(tg_id) - ) + 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() -) +@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 + callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any ): tg_id = callback_data.tg_id amount = callback_data.data @@ -292,19 +229,12 @@ async def handle_balance_add( await callback_query.message.edit_text( text="✍️ Введите сумму, которую хотите добавить на баланс пользователя:", - reply_markup=build_users_balance_change_kb(tg_id) + 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 -): +@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") @@ -312,19 +242,12 @@ async def handle_balance_take( await callback_query.message.edit_text( text="✍️ Введите сумму, которую хотите вычесть из баланса пользователя:", - reply_markup=build_users_balance_change_kb(tg_id) + 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 -): +@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") @@ -332,27 +255,19 @@ async def handle_balance_set( await callback_query.message.edit_text( text="✍️ Введите баланс, который хотите установить пользователю:", - reply_markup=build_users_balance_change_kb(tg_id) + 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 -): +@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) + text="🚫 Пожалуйста, введите корректную сумму!", reply_markup=build_users_balance_change_kb(tg_id) ) return @@ -368,21 +283,12 @@ async def handle_balance_input( 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) - ) + 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() -) +@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 + callback_query: CallbackQuery, callback_data: CallbackData, session: Any, update: bool = False ): email = callback_data.data key_details = await get_key_details(email, session) @@ -403,42 +309,24 @@ async def handle_key_edit( ) 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) - ) + 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) + 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 -): +@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) - ) + 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() -) +@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "add"), IsAdminFilter()) async def handle_expiry_add( - callback_query: CallbackQuery, - callback_data: AdminUserKeyEditorCallback, - state: FSMContext, - session: Any + callback_query: CallbackQuery, callback_data: AdminUserKeyEditorCallback, state: FSMContext, session: Any ): tg_id = callback_data.tg_id email = callback_data.data @@ -463,18 +351,13 @@ async def handle_expiry_add( await callback_query.message.edit_text( text="✍️ Введите количество дней, которое хотите добавить к времени действия ключа:", - reply_markup=build_users_key_show_kb(tg_id, email) + reply_markup=build_users_key_show_kb(tg_id, email), ) -@router.callback_query( - AdminUserKeyEditorCallback.filter(F.action == "take"), - IsAdminFilter() -) +@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "take"), IsAdminFilter()) async def handle_expiry_take( - callback_query: CallbackQuery, - callback_data: AdminUserKeyEditorCallback, - state: FSMContext + callback_query: CallbackQuery, callback_data: AdminUserKeyEditorCallback, state: FSMContext ): tg_id = callback_data.tg_id email = callback_data.data @@ -484,18 +367,13 @@ async def handle_expiry_take( await callback_query.message.edit_text( text="✍️ Введите количество дней, которое хотите вычесть из времени действия ключа:", - reply_markup=build_users_key_show_kb(tg_id, email) + reply_markup=build_users_key_show_kb(tg_id, email), ) -@router.callback_query( - AdminUserKeyEditorCallback.filter(F.action == "set"), - IsAdminFilter() -) +@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "set"), IsAdminFilter()) async def handle_expiry_set( - callback_query: CallbackQuery, - callback_data: AdminUserKeyEditorCallback, - state: FSMContext + callback_query: CallbackQuery, callback_data: AdminUserKeyEditorCallback, state: FSMContext ): tg_id = callback_data.tg_id email = callback_data.data @@ -509,21 +387,11 @@ async def handle_expiry_set( "\n Пример: 2025-02-09 09:01" ) - await callback_query.message.edit_text( - text=text, - reply_markup=build_users_key_show_kb(tg_id, email) - ) + 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 -): +@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") @@ -532,7 +400,7 @@ async def handle_expiry_time_input( 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) + reply_markup=build_users_key_show_kb(tg_id, email), ) return @@ -555,31 +423,19 @@ async def handle_expiry_time_input( 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 - ) + 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"🚫 Пожалуйста, используйте корректный формат даты!" + text = "🚫 Пожалуйста, используйте корректный формат даты!" except Exception as e: text = f"❗ Произошла ошибка во время изменения времени действия ключа: {e}" - await message.answer( - text=text, - reply_markup=build_users_key_show_kb(tg_id, email) - ) + 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 -): +@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 @@ -591,104 +447,65 @@ async def handle_update_key( 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) + 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 -): +@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 - ) + 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) + 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) + text="❓ Вы уверены, что хотите удалить ключ?", reply_markup=build_key_delete_kb(callback_data.tg_id, email) ) -@router.callback_query( - AdminUserEditorCallback.filter(F.action == "users_delete_key_confirm"), - IsAdminFilter() -) +@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 + 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 - ) + 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() + clusters = await get_servers() 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) - ) + 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 delete_key(client_id, session) - await callback_query.message.edit_text( - text="✅ Ключ успешно удален.", - reply_markup=kb - ) + await callback_query.message.edit_text(text="✅ Ключ успешно удален.", reply_markup=kb) else: - await callback_query.message.edit_text( - text="🚫 Ключ не найден или уже удален.", - reply_markup=kb - ) + 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 -): +@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) + 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() -) +@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 + 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) @@ -697,8 +514,8 @@ async def handle_delete_user_confirm( try: tasks = [] for email, client_id in key_records: - servers = await get_servers_from_db() - for cluster_id, cluster in servers.items(): + servers = await get_servers() + 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: @@ -709,8 +526,7 @@ async def handle_delete_user_confirm( 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) + text=f"🗑️ Пользователь с ID {tg_id} был удален.", reply_markup=build_editor_kb(callback_data.tg_id) ) except Exception as e: logger.error(f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}") @@ -719,37 +535,19 @@ async def handle_delete_user_confirm( ) -@router.callback_query( - AdminUserEditorCallback.filter(F.action == "users_editor"), - IsAdminFilter() -) +@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 + 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 - ) + 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 + 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 - ) + balance = await session.fetchval("SELECT balance FROM connections WHERE tg_id = $1", tg_id) if balance is None: await message.answer( @@ -758,15 +556,9 @@ async def process_user_search( ) 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 - ) + 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"📊 Информация о пользователе" @@ -780,17 +572,11 @@ async def process_user_search( if edit: try: - await message.edit_text( - text=text, - reply_markup=kb - ) + await message.edit_text(text=text, reply_markup=kb) except TelegramBadRequest: pass else: - await message.answer( - text=text, - reply_markup=kb - ) + await message.answer(text=text, reply_markup=kb) async def get_key_details(email, session): @@ -827,14 +613,12 @@ async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exce 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 - ) + 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() + clusters = await get_servers() async def update_key_on_all_servers(): tasks = [ @@ -859,7 +643,8 @@ async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exce 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, + "SELECT balance FROM connections WHERE tg_id = $1", + tg_id, ) except Exception as e: logger.error(f"Ошибка при получении баланса для пользователя {tg_id}: {e}") @@ -870,7 +655,8 @@ 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, + balance, + tg_id, ) except Exception as e: logger.error(f"Ошибка при добавлении баланса для пользователя {tg_id}: {e}") @@ -880,7 +666,8 @@ 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, + balance, + tg_id, ) except Exception as e: logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}") diff --git a/handlers/buttons/gifts.py b/handlers/buttons/gifts.py index f80d956a..3f9df975 100644 --- a/handlers/buttons/gifts.py +++ b/handlers/buttons/gifts.py @@ -1,6 +1,6 @@ GIFT = "🎁 Подарить подписку" MY_GIFTS = "🎁 Мои подарки" PROFILE = "👤 Личный кабинет" -BACK = "🔙 Назад" +BACK = "⬅️ Назад" GIFTS_ABOUT = "Дарите подарки и следите, чтобы они дошли до адресата! 🎄" SHARE_GIFT = "🎁 Поделиться подарком" diff --git a/handlers/buttons/yookassa.py b/handlers/buttons/yookassa.py index 52401db4..5bb26cbb 100644 --- a/handlers/buttons/yookassa.py +++ b/handlers/buttons/yookassa.py @@ -3,6 +3,10 @@ PAY_2 = "Оплатить" BACK = "⬅️ Назад" CUSTOM_SUM = "💰 Ввести свою сумму" PROFILE = "👤 Личный кабинет" -KEY_CREATION_PAYMENT_MESSAGE = "Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:" -KEY_RENEWAL_PAYMENT_MESSAGE = "Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:" +KEY_CREATION_PAYMENT_MESSAGE = ( + "Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:" +) +KEY_RENEWAL_PAYMENT_MESSAGE = ( + "Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:" +) DEFAULT_PAYMENT_MESSAGE = "Вы выбрали пополнение на {amount} рублей. Перейдите по ссылке для оплаты:" diff --git a/handlers/captcha.py b/handlers/captcha.py new file mode 100644 index 00000000..ef3bbc30 --- /dev/null +++ b/handlers/captcha.py @@ -0,0 +1,70 @@ +import random +import secrets +from typing import Any + +from aiogram import F, Router +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from config import CAPTCHA_EMOJIS +from logger import logger + +router = Router() + + +async def generate_captcha(message: Message, state: FSMContext): + """Генерирует новую капчу и сохраняет правильный ответ в состоянии + + Пример словаря CAPTCHA_EMOJIS: + { + "🐶": "собаку", # Собака + "🐱": "кошку", # Кошка + "🐭": "мышь", # Мышь + "🐹": "хомяка", # Хомяк + "🐰": "кролика", # Кролик + ... + } + """ + # Выбираем случайный эмодзи и его описание из конфига + correct_emoji, correct_text = secrets.choice(list(CAPTCHA_EMOJIS.items())) + + # Получаем 3 случайных неправильных эмодзи + wrong_emojis = random.sample([e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3) + + # Создаем список всех эмодзи и перемешиваем их + all_emojis = [correct_emoji] + wrong_emojis + random.shuffle(all_emojis) + + # Сохраняем правильный ответ в состоянии + await state.update_data(correct_emoji=correct_emoji) + await state.update_data(message=message) + + # Создаем клавиатуру + builder = InlineKeyboardBuilder() + for emoji in all_emojis: + builder.button(text=emoji, callback_data=f"captcha_{emoji}") + builder.adjust(2, 2) + + return { + "text": f"🔒 Для подтверждения что вы не робот, выберите кнопку с {correct_text}", + "markup": builder.as_markup(), + } + + +@router.callback_query(F.data.startswith("captcha_")) +async def check_captcha(callback: CallbackQuery, state: FSMContext, session: Any, admin: bool): + from handlers.start import start_command + + selected_emoji = callback.data.split("captcha_")[1] + state_data = await state.get_data() + correct_emoji = state_data.get("correct_emoji") + message = state_data.get("message", callback.message) + + if selected_emoji == correct_emoji: + logger.info(f"Пользователь {callback.message.chat.id} успешно прошел капчу") + await start_command(message, state, session, admin, False) + else: + logger.warning(f"Пользователь {callback.message.chat.id} неверно ответил на капчу") + captcha = await generate_captcha(message, state) + await callback.message.answer(text=captcha["text"], reply_markup=captcha["markup"]) diff --git a/handlers/coupons.py b/handlers/coupons.py index 15b3aa55..0a125da2 100644 --- a/handlers/coupons.py +++ b/handlers/coupons.py @@ -1,4 +1,3 @@ -from datetime import datetime from typing import Any from aiogram import F, Router, types @@ -7,7 +6,13 @@ from aiogram.fsm.state import State, StatesGroup from aiogram.types import InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder -from database import update_balance +from database import ( + check_coupon_usage, + create_coupon_usage, + get_coupon_by_code, + update_balance, + update_coupon_usage_count, +) class CouponActivationState(StatesGroup): @@ -18,9 +23,7 @@ router = Router() @router.callback_query(F.data == "activate_coupon") -async def handle_activate_coupon( - callback_query: types.CallbackQuery, state: FSMContext -): +async def handle_activate_coupon(callback_query: types.CallbackQuery, state: FSMContext): builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) @@ -40,58 +43,25 @@ async def process_coupon_code(message: types.Message, state: FSMContext, session builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) - await message.answer( - activation_result, reply_markup=builder.as_markup(), parse_mode="HTML" - ) + await message.answer(activation_result, reply_markup=builder.as_markup()) await state.clear() async def activate_coupon(user_id: int, coupon_code: str, session: Any): - coupon_record = await session.fetchrow( - """ - SELECT id, usage_limit, usage_count, is_used, amount - FROM coupons - WHERE code = $1 AND (usage_count < usage_limit OR usage_limit = 0) AND is_used = FALSE - """, - coupon_code, - ) + coupon_record = await get_coupon_by_code(coupon_code, session) if not coupon_record: return "❌ Купон не найден 🚫 или его использование ограничено. 🔒 Пожалуйста, проверьте код и попробуйте снова. 🔍" - usage_exists = await session.fetchrow( - """ - SELECT 1 FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2 - """, - coupon_record["id"], - user_id, - ) + usage_exists = await check_coupon_usage(coupon_record["id"], user_id, session) if usage_exists: return "❌ Вы уже активировали этот купон. 🚫 Купоны могут быть активированы только один раз. 🔒" coupon_amount = coupon_record["amount"] - async with session.transaction(): - await session.execute( - """ - UPDATE coupons - SET usage_count = usage_count + 1, - is_used = CASE WHEN usage_count + 1 >= usage_limit AND usage_limit > 0 THEN TRUE ELSE FALSE END - WHERE id = $1 - """, - coupon_record["id"], - ) + await update_coupon_usage_count(coupon_record["id"], session) + await create_coupon_usage(coupon_record["id"], user_id, session) - await session.execute( - """ - INSERT INTO coupon_usages (coupon_id, user_id, used_at) - VALUES ($1, $2, $3) - """, - coupon_record["id"], - user_id, - datetime.utcnow(), - ) - - await update_balance(user_id, coupon_amount) + await update_balance(user_id, coupon_amount, session) return f"✅ Купон успешно активирован! 🎉\n\nНа ваш баланс добавлено {coupon_amount} рублей 💰." diff --git a/handlers/donate.py b/handlers/donate.py index ad460085..3de7d323 100644 --- a/handlers/donate.py +++ b/handlers/donate.py @@ -22,11 +22,7 @@ async def process_donate(callback_query: types.CallbackQuery, state: FSMContext) await state.clear() builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot" - ) - ) + builder.row(InlineKeyboardButton(text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot")) builder.row( InlineKeyboardButton( text="💰 Ввести сумму доната", @@ -44,14 +40,10 @@ async def process_donate(callback_query: types.CallbackQuery, state: FSMContext) @router.callback_query(F.data == "enter_custom_donate_amount") -async def process_enter_donate_amount( - callback_query: types.CallbackQuery, state: FSMContext -): +async def process_enter_donate_amount(callback_query: types.CallbackQuery, state: FSMContext): builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="donate")) - await callback_query.message.answer( - "💸 Введите сумму доната в рублях:", reply_markup=builder.as_markup() - ) + await callback_query.message.answer("💸 Введите сумму доната в рублях:", reply_markup=builder.as_markup()) await state.set_state(DonateState.entering_donate_amount) @@ -60,9 +52,7 @@ async def process_donate_amount_input(message: types.Message, state: FSMContext) if message.text.isdigit(): amount = int(message.text) if amount // RUB_TO_XTR <= 0: - await message.answer( - f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:" - ) + await message.answer(f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:") return await state.update_data(amount=amount) @@ -99,9 +89,7 @@ async def on_successful_donate(message: types.Message, state: FSMContext): try: amount = float(message.successful_payment.invoice_payload.split("_")[0]) builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile") - ) + builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) await message.answer( text=f"🙏 Спасибо за донат {amount} рублей! Ваша поддержка очень важна для нас. 💖", reply_markup=builder.as_markup(), diff --git a/handlers/instructions/instructions.py b/handlers/instructions/instructions.py index 215b1452..89053024 100644 --- a/handlers/instructions/instructions.py +++ b/handlers/instructions/instructions.py @@ -1,12 +1,13 @@ import os from typing import Any -import asyncpg +import aiofiles from aiogram import F, Router, types from aiogram.types import BufferedInputFile, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder -from config import CONNECT_MACOS, CONNECT_WINDOWS, DATABASE_URL, SUPPORT_CHAT_URL +from config import CONNECT_MACOS, CONNECT_WINDOWS, SUPPORT_CHAT_URL +from database import get_key_details from handlers.texts import ( CONNECT_TV_TEXT, INSTRUCTION_PC, @@ -29,9 +30,7 @@ async def send_instructions( if not os.path.isfile(image_path): if isinstance(callback_query_or_message, types.CallbackQuery): - await callback_query_or_message.message.answer( - "Файл изображения не найден." - ) + await callback_query_or_message.message.answer("Файл изображения не найден.") else: await callback_query_or_message.answer("Файл изображения не найден.") return @@ -47,9 +46,10 @@ async def send_instructions( else: send_photo = callback_query_or_message.answer_photo - with open(image_path, "rb") as image_from_buffer: + async with aiofiles.open(image_path, "rb") as image_from_buffer: + image_data = await image_from_buffer.read() await send_photo( - BufferedInputFile(image_from_buffer.read(), filename="instructions.jpg"), + BufferedInputFile(image_data, filename="instructions.jpg"), caption=instructions_message, reply_markup=builder.as_markup(), ) @@ -57,23 +57,12 @@ async def send_instructions( @router.callback_query(F.data.startswith("connect_pc|")) async def process_connect_pc(callback_query: types.CallbackQuery, session: Any): - tg_id = callback_query.message.chat.id key_name = callback_query.data.split("|")[1] - record = await session.fetchrow( - """ - SELECT k.key - FROM keys k - WHERE k.tg_id = $1 AND k.email = $2 - """, - tg_id, - key_name, - ) + record = await get_key_details(key_name, session) if not record: - await callback_query.message.answer( - "❌ Ключ не найден. Проверьте имя ключа. 🔍" - ) + await callback_query.message.answer("❌ Ключ не найден. Проверьте имя ключа. 🔍") return key = record["key"] @@ -81,21 +70,14 @@ async def process_connect_pc(callback_query: types.CallbackQuery, session: Any): instruction_message = f"{key_message}{INSTRUCTION_PC}" builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="💻 Подключить Windows", url=f"{CONNECT_WINDOWS}{key}" - ) - ) - builder.row( - InlineKeyboardButton(text="💻 Подключить MacOS", url=f"{CONNECT_MACOS}{key}") - ) + builder.row(InlineKeyboardButton(text="💻 Подключить Windows", url=f"{CONNECT_WINDOWS}{key}")) + builder.row(InlineKeyboardButton(text="💻 Подключить MacOS", url=f"{CONNECT_MACOS}{key}")) builder.row(InlineKeyboardButton(text="🆘 Поддержка", url=f"{SUPPORT_CHAT_URL}")) builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) await callback_query.message.answer( instruction_message, reply_markup=builder.as_markup(), - parse_mode="HTML", disable_web_page_preview=True, ) @@ -105,56 +87,33 @@ async def process_connect_tv(callback_query: types.CallbackQuery): key_name = callback_query.data.split("|")[1] builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="▶ Продолжить", callback_data=f"continue_tv|{key_name}" - ) - ) + builder.row(InlineKeyboardButton(text="▶ Продолжить", callback_data=f"continue_tv|{key_name}")) builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) await callback_query.message.answer( text=CONNECT_TV_TEXT, reply_markup=builder.as_markup(), - parse_mode="HTML", disable_web_page_preview=True, ) @router.callback_query(F.data.startswith("continue_tv|")) -async def process_continue_tv(callback_query: types.CallbackQuery): +async def process_continue_tv(callback_query: types.CallbackQuery, session: Any): key_name = callback_query.data.split("|")[1] tg_id = callback_query.from_user.id logger.info(f"tg_id: {tg_id}, key_name: {key_name}") - conn = await asyncpg.connect(DATABASE_URL) - try: - record = await conn.fetchrow( - """ - SELECT k.key - FROM keys k - WHERE k.tg_id = $1 AND k.email = $2 - """, - tg_id, - key_name, - ) + record = await get_key_details(key_name, session) - logger.info(f"Query result: {record}") - finally: - await conn.close() + logger.info(f"Query result: {record}") subscription_link = record["key"] message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=subscription_link) builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="📖 Полная инструкция", url="https://vpn4tv.com/quick-guide.html" - ) - ) + builder.row(InlineKeyboardButton(text="📖 Полная инструкция", url="https://vpn4tv.com/quick-guide.html")) builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) - await callback_query.message.answer( - text=message_text, reply_markup=builder.as_markup(), parse_mode="HTML" - ) + await callback_query.message.answer(text=message_text, reply_markup=builder.as_markup()) diff --git a/handlers/keys/key_management.py b/handlers/keys/key_management.py index 038e5440..3330f2da 100644 --- a/handlers/keys/key_management.py +++ b/handlers/keys/key_management.py @@ -24,9 +24,10 @@ from config import ( USE_NEW_PAYMENT_FLOW, ) from database import ( + create_temporary_data, get_balance, + get_key_details, get_trial, - save_temporary_data, store_key, update_balance, ) @@ -56,16 +57,12 @@ class Form(StatesGroup): @router.callback_query(F.data == "create_key") -async def confirm_create_new_key( - callback_query: CallbackQuery, state: FSMContext, session: Any -): +async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any): tg_id = callback_query.message.chat.id logger.info(f"User {tg_id} confirmed creation of a new key.") - logger.info( - f"Balance for user {tg_id} is sufficient. Proceeding with key creation." - ) + logger.info(f"Balance for user {tg_id} is sufficient. Proceeding with key creation.") await handle_key_creation(tg_id, state, session, callback_query) @@ -84,9 +81,7 @@ async def handle_key_creation( expiry_time = current_time + timedelta(days=TRIAL_TIME) logger.info(f"Assigned 1-day trial to user {tg_id}.") - await session.execute( - "UPDATE connections SET trial = 1 WHERE tg_id = $1", tg_id - ) + await session.execute("UPDATE connections SET trial = 1 WHERE tg_id = $1", tg_id) await create_key(tg_id, expiry_time, state, session, message_or_query) else: builder = InlineKeyboardBuilder() @@ -108,9 +103,7 @@ async def handle_key_creation( ) ) - builder.row( - InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile") - ) + builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) await message_or_query.message.answer( "💳 Выберите тарифный план для создания нового ключа:", @@ -136,7 +129,7 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any): if balance < plan_price: required_amount = plan_price - balance - await save_temporary_data( + await create_temporary_data( session, tg_id, "waiting_for_payment", @@ -154,12 +147,8 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any): await handle_custom_amount_input(callback_query, session) else: builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="pay") - ) - builder.row( - InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile") - ) + builder.row(InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="pay")) + builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) await callback_query.message.answer( f"💳 Недостаточно средств. Для продолжения необходимо пополнить баланс на {required_amount}₽.", @@ -169,7 +158,7 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any): expiry_time = datetime.utcnow() + timedelta(days=duration_days) await create_key(tg_id, expiry_time, None, session, callback_query) - await update_balance(tg_id, -plan_price) + await update_balance(tg_id, -plan_price, session) async def create_key( @@ -201,15 +190,11 @@ async def create_key( builder = InlineKeyboardBuilder() for country in countries: callback_data = f"select_country|{country}|{expiry_time.isoformat()}" - builder.row( - InlineKeyboardButton( - text=country, callback_data=callback_data - ) - ) + builder.row(InlineKeyboardButton(text=country, callback_data=callback_data)) logger.info(f"[Country Selection] Добавлена кнопка для страны: {country} с callback_data: {callback_data}") - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="profile")) - logger.info("[Country Selection] Добавлена кнопка '🔙 Назад'.") + builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="profile")) + logger.info("[Country Selection] Добавлена кнопка '⬅️ Назад'.") if isinstance(message_or_query, Message): logger.info("[Country Selection] Сообщение пользователя - тип Message.") @@ -243,11 +228,7 @@ async def create_key( key_name = generate_random_email() logger.info(f"[Key Generation] Сгенерировано имя ключа: {key_name} для пользователя {tg_id}") - existing_key = await session.fetchrow( - "SELECT * FROM keys WHERE email = $1 AND tg_id = $2", - key_name, - tg_id, - ) + existing_key = await get_key_details(key_name, session) if not existing_key: break logger.warning(f"[Key Generation] Имя ключа {key_name} уже существует. Генерация нового.") @@ -368,16 +349,10 @@ async def finalize_key_creation( key_name = generate_random_email() logger.info(f"Generated random key name for user {tg_id}: {key_name}") - existing_key = await session.fetchrow( - "SELECT * FROM keys WHERE email = $1 AND tg_id = $2", - key_name, - tg_id, - ) + existing_key = await get_key_details(key_name, session) if not existing_key: break - logger.warning( - f"Key name '{key_name}' already exists for user {tg_id}. Generating a new one." - ) + logger.warning(f"Key name '{key_name}' already exists for user {tg_id}. Generating a new one.") client_id = str(uuid.uuid4()) email = key_name.lower() diff --git a/handlers/keys/key_utils.py b/handlers/keys/key_utils.py index c99f84b5..8de0861b 100644 --- a/handlers/keys/key_utils.py +++ b/handlers/keys/key_utils.py @@ -3,9 +3,9 @@ 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, PUBLIC_LINK -from database import get_servers_from_db, store_key +from client import ClientConfig, add_client, delete_client, extend_client_key +from config import ADMIN_PASSWORD, ADMIN_USERNAME, LIMIT_IP, PUBLIC_LINK, SUPERNODE, TOTAL_GB +from database import get_servers, store_key from handlers.utils import get_least_loaded_cluster from logger import logger @@ -15,7 +15,7 @@ async def create_key_on_cluster(cluster_id: str, tg_id: int, client_id: str, ema Создает ключ на всех серверах указанного кластера. """ try: - servers = await get_servers_from_db() + servers = await get_servers() cluster = servers.get(cluster_id) if not cluster: @@ -75,9 +75,7 @@ async def create_client_on_server( server_name = server_info.get("server_name", "unknown") if not inbound_id: - logger.warning( - f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск." - ) + logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.") return if SUPERNODE: @@ -89,24 +87,27 @@ async def create_client_on_server( await add_client( xui, - client_id, - unique_email, - tg_id, - limit_ip=LIMIT_IP, - total_gb=TOTAL_GB, - expiry_time=expiry_timestamp, - enable=True, - flow="xtls-rprx-vision", - inbound_id=int(inbound_id), - sub_id=sub_id, + ClientConfig( + client_id=client_id, + email=unique_email, + tg_id=tg_id, + limit_ip=LIMIT_IP, + total_gb=TOTAL_GB, + expiry_time=expiry_timestamp, + enable=True, + flow="xtls-rprx-vision", + inbound_id=int(inbound_id), + sub_id=sub_id, + ), ) 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() + servers = await get_servers() cluster = servers.get(cluster_id) if not cluster: @@ -124,9 +125,7 @@ async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, to server_name = server_info.get("server_name", "unknown") if not inbound_id: - logger.warning( - f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск." - ) + logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.") continue if SUPERNODE: @@ -137,37 +136,20 @@ async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, to sub_id = unique_email tasks.append( - extend_client_key( - xui, - int(inbound_id), - unique_email, - new_expiry_time, - client_id, - total_gb, - sub_id - ) + extend_client_key(xui, int(inbound_id), unique_email, new_expiry_time, client_id, total_gb, sub_id) ) await asyncio.gather(*tasks) except Exception as e: - logger.error( - f"Не удалось продлить ключ {client_id} в кластере {cluster_id}: {e}" - ) + logger.error(f"Не удалось продлить ключ {client_id} в кластере {cluster_id}: {e}") raise e -async def delete_key_from_db(client_id, session): - try: - await session.execute("DELETE FROM keys WHERE client_id = $1", client_id) - except Exception as e: - logger.error(f"Ошибка при удалении ключа {client_id} из базы данных: {e}") - - async def delete_key_from_cluster(cluster_id, email, client_id): """Удаление ключа с серверов в кластере""" try: - servers = await get_servers_from_db() + servers = await get_servers() cluster = servers.get(cluster_id) if not cluster: @@ -200,15 +182,13 @@ async def delete_key_from_cluster(cluster_id, email, client_id): await asyncio.gather(*tasks) except Exception as e: - logger.error( - f"Не удалось удалить ключ {client_id} в кластере {cluster_id}: {e}" - ) + logger.error(f"Не удалось удалить ключ {client_id} в кластере {cluster_id}: {e}") raise e async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id): try: - servers = await get_servers_from_db() + servers = await get_servers() cluster = servers.get(cluster_id) if not cluster: @@ -232,29 +212,27 @@ async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id tasks.append( add_client( xui, - client_id, - email, - tg_id, - limit_ip=LIMIT_IP, - total_gb=TOTAL_GB, - expiry_time=expiry_time, - enable=True, - flow="xtls-rprx-vision", - inbound_id=int(inbound_id), - sub_id=email + ClientConfig( + client_id=client_id, + email=email, + tg_id=tg_id, + limit_ip=LIMIT_IP, + total_gb=TOTAL_GB, + expiry_time=expiry_time, + enable=True, + flow="xtls-rprx-vision", + inbound_id=int(inbound_id), + sub_id=email, + ), ) ) await asyncio.gather(*tasks) - logger.info( - f"Ключ успешно обновлен для {client_id} на всех серверах в кластере {cluster_id}" - ) + logger.info(f"Ключ успешно обновлен для {client_id} на всех серверах в кластере {cluster_id}") except Exception as e: - logger.error( - f"Ошибка при обновлении ключа на серверах кластера {cluster_id} для {client_id}: {e}" - ) + logger.error(f"Ошибка при обновлении ключа на серверах кластера {cluster_id} для {client_id}: {e}") raise e diff --git a/handlers/keys/keys.py b/handlers/keys/keys.py index b62466c7..018d3f1e 100644 --- a/handlers/keys/keys.py +++ b/handlers/keys/keys.py @@ -4,12 +4,12 @@ import os from datetime import datetime, timedelta from typing import Any +import aiofiles import asyncpg 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 ( @@ -27,10 +27,14 @@ from config import ( USE_NEW_PAYMENT_FLOW, ) from database import ( + check_server_name_by_cluster, + create_temporary_data, delete_key, get_balance, - get_servers_from_db, - save_temporary_data, + get_key_details, + get_keys, + get_keys_by_server, + get_servers, update_balance, update_key_expiry, ) @@ -44,11 +48,11 @@ from handlers.buttons.add_subscribe import ( ) from handlers.keys.key_utils import ( delete_key_from_cluster, - delete_key_from_db, renew_key_in_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, @@ -67,7 +71,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 @@ -79,21 +83,12 @@ async def process_callback_or_message_view_keys( send_photo = callback_query_or_message.answer_photo try: - records = await session.fetch( - """ - SELECT email, client_id, expiry_time - FROM keys - WHERE tg_id = $1 - """, - chat_id, - ) + records = await get_keys(chat_id, session) inline_keyboard, response_message = build_keys_response(records) image_path = os.path.join("img", "pic_keys.jpg") - await send_with_optional_image( - send_message, send_photo, image_path, response_message, inline_keyboard - ) + await send_with_optional_image(send_message, send_photo, image_path, response_message, inline_keyboard) except Exception as e: error_message = f"Ошибка при получении ключей: {e}" await send_message(text=error_message) @@ -115,9 +110,7 @@ def build_keys_response(records): if expiry_time: expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz) - formatted_date_full = expiry_date_full.strftime( - "до %d %B %Y года, %H:%M" - ).lower() + formatted_date_full = expiry_date_full.strftime("до %d %B %Y года, %H:%M").lower() formatted_date_short = expiry_date_full.strftime("до %d %B").lower() else: @@ -125,41 +118,31 @@ def build_keys_response(records): formatted_date_short = "без срока действия" button_text = f"{key_name} ({formatted_date_short})" - builder.row( - InlineKeyboardButton( - text=button_text, callback_data=f"view_key|{key_name}" - ) - ) + builder.row(InlineKeyboardButton(text=button_text, callback_data=f"view_key|{key_name}")) response_message += f"• {key_name} ({formatted_date_full})\n" else: response_message = ( - "🔑 У вас пока нет подписок.\n\n" - "Вы можете создать новую подписку для подключения устройств." + "🔑 У вас пока нет подписок.\n\nВы можете создать новую подписку для подключения устройств." ) - builder.row( - InlineKeyboardButton(text="➕ Добавить подписку", callback_data="create_key") - ) + builder.row(InlineKeyboardButton(text="➕ Добавить подписку", callback_data="create_key")) builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) inline_keyboard = builder.as_markup() return inline_keyboard, response_message -async def send_with_optional_image( - send_message, send_photo, image_path, text, keyboard -): +async def send_with_optional_image(send_message, send_photo, image_path, text, keyboard): """ Отправляет сообщение с изображением, если файл существует. В противном случае отправляет только текст. """ if os.path.isfile(image_path): - with open(image_path, "rb") as image_file: + async with aiofiles.open(image_path, "rb") as image_file: + image_data = await image_file.read() await send_photo( - photo=BufferedInputFile( - image_file.read(), filename=os.path.basename(image_path) - ), + photo=BufferedInputFile(image_data, filename=os.path.basename(image_path)), caption=text, reply_markup=keyboard, ) @@ -175,15 +158,7 @@ async def process_callback_view_key(callback_query: types.CallbackQuery, session tg_id = callback_query.message.chat.id key_name = callback_query.data.split("|")[1] try: - record = await session.fetchrow( - """ - SELECT k.expiry_time, k.server_id, k.key - FROM keys k - WHERE k.tg_id = $1 AND k.email = $2 - """, - tg_id, - key_name, - ) + record = await get_key_details(key_name, session) if record: key = record["key"] @@ -207,9 +182,7 @@ async def process_callback_view_key(callback_query: types.CallbackQuery, session ) formatted_expiry_date = expiry_date.strftime("%d %B %Y года") - response_message = key_message( - key, formatted_expiry_date, days_left_message, server_name - ) + response_message = key_message(key, formatted_expiry_date, days_left_message, server_name) builder = InlineKeyboardBuilder() @@ -223,46 +196,28 @@ async def process_callback_view_key(callback_query: types.CallbackQuery, session builder.row( InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS), - InlineKeyboardButton( - text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID - ), + InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID), ) builder.row( InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key}"), - InlineKeyboardButton( - text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key}" - ), + InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key}"), ) builder.row( - InlineKeyboardButton( - text=PC_BUTTON, callback_data=f"connect_pc|{key_name}" - ), - InlineKeyboardButton( - text=TV_BUTTON, callback_data=f"connect_tv|{key_name}" - ), + InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"), + InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"), ) if ENABLE_DELETE_KEY_BUTTON: builder.row( - InlineKeyboardButton( - text="⏳ Продлить", callback_data=f"renew_key|{key_name}" - ), - InlineKeyboardButton( - text="❌ Удалить", callback_data=f"delete_key|{key_name}" - ), + InlineKeyboardButton(text="⏳ Продлить", callback_data=f"renew_key|{key_name}"), + InlineKeyboardButton(text="❌ Удалить", callback_data=f"delete_key|{key_name}"), ) else: - builder.row( - InlineKeyboardButton( - text="⏳ Продлить", callback_data=f"renew_key|{key_name}" - ) - ) - - builder.row( - InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile") - ) + builder.row(InlineKeyboardButton(text="⏳ Продлить", callback_data=f"renew_key|{key_name}")) + builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys")) + builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) keyboard = builder.as_markup() @@ -272,9 +227,10 @@ async def process_callback_view_key(callback_query: types.CallbackQuery, session await callback_query.message.answer("Файл изображения не найден.") return - with open(image_path, "rb") as image_file: + async with aiofiles.open(image_path, "rb") as image_file: + image_data = await image_file.read() await callback_query.message.answer_photo( - photo=BufferedInputFile(image_file.read(), filename="pic_view.jpg"), + photo=BufferedInputFile(image_data, filename="pic_view.jpg"), caption=response_message, reply_markup=keyboard, ) @@ -291,9 +247,7 @@ 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 -): +async def process_callback_update_subscription(callback_query: types.CallbackQuery, session: Any): tg_id = callback_query.message.chat.id email = callback_query.data.split("|")[1] @@ -302,9 +256,7 @@ async def process_callback_update_subscription( 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}" - ) + await handle_error(tg_id, callback_query, f"Ошибка при обновлении подписки: {e}") @router.callback_query(F.data.startswith("delete_key|")) @@ -319,11 +271,7 @@ async def process_callback_delete_key(callback_query: types.CallbackQuery): callback_data=f"confirm_delete|{client_id}", ) ], - [ - types.InlineKeyboardButton( - text="❌ Нет, отменить", callback_data="view_keys" - ) - ], + [types.InlineKeyboardButton(text="❌ Нет, отменить", callback_data="view_keys")], ] ) @@ -341,14 +289,7 @@ async def process_callback_renew_key(callback_query: types.CallbackQuery, sessio tg_id = callback_query.message.chat.id key_name = callback_query.data.split("|")[1] try: - record = await session.fetchrow( - """ - SELECT client_id, expiry_time - FROM keys - WHERE email = $1 - """, - key_name, - ) + record = await get_key_details(key_name, session) if record: client_id = record["client_id"] @@ -360,9 +301,8 @@ async def process_callback_renew_key(callback_query: types.CallbackQuery, sessio months = plan_details["months"] price = plan_details["price"] discount = DISCOUNTS.get(plan_id, 0) - button_text = ( - f'📅 {months} месяц{"а" if months > 1 else ""} ({price} руб.)' - + (f' {discount}% скидка' if discount > 0 else "") + button_text = f"📅 {months} месяц{'а' if months > 1 else ''} ({price} руб.)" + ( + f" {discount}% скидка" if discount > 0 else "" ) builder.row( InlineKeyboardButton( @@ -371,19 +311,13 @@ async def process_callback_renew_key(callback_query: types.CallbackQuery, sessio ) ) - builder.row( - InlineKeyboardButton( - text="🔙 Назад", callback_data="view_keys" - ) - ) + builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys")) balance = await get_balance(tg_id) response_message = PLAN_SELECTION_MSG.format( balance=balance, - expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime( - "%Y-%m-%d %H:%M:%S" - ), + expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"), ) await callback_query.message.answer( @@ -397,38 +331,30 @@ 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 -): +async def process_callback_confirm_delete(callback_query: types.CallbackQuery, session: Any): email = callback_query.data.split("|")[1] try: - record = await session.fetchrow( - "SELECT client_id FROM keys WHERE email = $1", email - ) + record = await get_key_details(email, session) if record: client_id = record["client_id"] response_message = "Ключ успешно удален." - back_button = types.InlineKeyboardButton( - text="Назад", callback_data="view_keys" - ) + back_button = types.InlineKeyboardButton(text="Назад", callback_data="view_keys") keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]]) - await delete_key(client_id) + await delete_key(client_id, session) await callback_query.message.answer( response_message, reply_markup=keyboard, ) - servers = await get_servers_from_db() + servers = await get_servers(session) async def delete_key_from_servers(): try: tasks = [] - for cluster_id, cluster in servers.items(): - tasks.append( - delete_key_from_cluster(cluster_id, email, client_id) - ) + for cluster_id, _cluster in servers.items(): + tasks.append(delete_key_from_cluster(cluster_id, email, client_id)) await asyncio.gather(*tasks) @@ -437,13 +363,11 @@ async def process_callback_confirm_delete( asyncio.create_task(delete_key_from_servers()) - await delete_key_from_db(client_id, session) + await delete_key(client_id, session) else: response_message = "Ключ не найден или уже удален." - back_button = types.InlineKeyboardButton( - text="Назад", callback_data="view_keys" - ) + back_button = types.InlineKeyboardButton(text="Назад", callback_data="view_keys") keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]]) await callback_query.message.answer( @@ -464,10 +388,7 @@ async def process_callback_renew_plan(callback_query: types.CallbackQuery, sessi total_gb = TOTAL_GB * gb_multiplier.get(plan, 1) if TOTAL_GB > 0 else 0 try: - record = await session.fetchrow( - "SELECT email, expiry_time FROM keys WHERE client_id = $1", - client_id, - ) + record = await get_keys_by_server(tg_id, client_id, session) if record: email = record["email"] @@ -486,9 +407,10 @@ async def process_callback_renew_plan(callback_query: types.CallbackQuery, sessi required_amount = cost - balance logger.info( - f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}") + f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}" + ) - await save_temporary_data( + await create_temporary_data( session, tg_id, "waiting_for_renewal_payment", @@ -543,15 +465,7 @@ async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_g await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup()) conn = await asyncpg.connect(DATABASE_URL) - key_info = await conn.fetchrow( - """ - SELECT server_id - FROM keys - WHERE tg_id = $1 AND client_id = $2 - """, - tg_id, - client_id, - ) + key_info = await get_key_details(email, conn) if not key_info: logger.error(f"[RENEW] Ключ с client_id {client_id} для пользователя {tg_id} не найден.") @@ -561,14 +475,7 @@ async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_g server_id = key_info["server_id"] if USE_COUNTRY_SELECTION: - cluster_info = await conn.fetchrow( - """ - SELECT cluster_name - FROM servers - WHERE server_name = $1 - """, - server_id, - ) + cluster_info = await check_server_name_by_cluster(server_id, conn) if not cluster_info: logger.error(f"[RENEW] Сервер {server_id} не найден в таблице servers.") @@ -592,8 +499,8 @@ async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_g total_gb, ) - await update_key_expiry(client_id, new_expiry_time) - await update_balance(tg_id, -cost) + await update_key_expiry(client_id, new_expiry_time, conn) + await update_balance(tg_id, -cost, conn) logger.info(f"[RENEW] Ключ {client_id} успешно продлён на {plan} мес. для пользователя {tg_id}.") await renew_key_on_cluster() diff --git a/handlers/keys/subscriptions.py b/handlers/keys/subscriptions.py index 35b19f29..ca2bea1a 100644 --- a/handlers/keys/subscriptions.py +++ b/handlers/keys/subscriptions.py @@ -7,12 +7,13 @@ import asyncpg from aiohttp import web from config import DATABASE_URL, PROJECT_NAME, SUB_MESSAGE, SUPERNODE, TRANSITION_DATE_STR -from database import get_servers_from_db +from database import get_key_details, get_servers from logger import logger # Глобальная переменная для пула соединений db_pool = None + async def init_db_pool(): """ Инициализация пула соединений, если он ещё не создан. @@ -21,6 +22,7 @@ async def init_db_pool(): if not db_pool: db_pool = await asyncpg.create_pool(dsn=DATABASE_URL, min_size=5, max_size=20) + async def fetch_url_content(url, tg_id): try: logger.info(f"Получение URL: {url} для tg_id: {tg_id}") @@ -32,17 +34,16 @@ async def fetch_url_content(url, tg_id): logger.info(f"Успешно получен контент с {url} для tg_id: {tg_id}") return base64.b64decode(content).decode("utf-8").split("\n") else: - logger.error( - f"Не удалось получить {url} для tg_id: {tg_id}, статус: {response.status}" - ) + logger.error(f"Не удалось получить {url} для tg_id: {tg_id}, статус: {response.status}") return [] - except asyncio.TimeoutError: + except TimeoutError: logger.error(f"Таймаут при получении {url} для tg_id: {tg_id}") return [] except Exception as e: logger.error(f"Ошибка при получении {url} для tg_id: {tg_id}: {e}") return [] + async def combine_unique_lines(urls, tg_id, query_string): if SUPERNODE: logger.info(f"Режим SUPERNODE активен. Возвращаем первую ссылку для tg_id: {tg_id}") @@ -51,9 +52,7 @@ async def combine_unique_lines(urls, tg_id, query_string): url_with_query = f"{urls[0]}?{query_string}" if query_string else urls[0] return await fetch_url_content(url_with_query, tg_id) - logger.info( - f"Начинаем объединение подписок для tg_id: {tg_id}, запрос: {query_string}" - ) + logger.info(f"Начинаем объединение подписок для tg_id: {tg_id}, запрос: {query_string}") urls_with_query = [f"{url}?{query_string}" if query_string else url for url in urls] logger.info(f"Составлены URL-адреса: {urls_with_query}") @@ -65,19 +64,17 @@ async def combine_unique_lines(urls, tg_id, query_string): for lines in results: all_lines.update(filter(None, lines)) - logger.info( - f"Объединено {len(all_lines)} строк после фильтрации и удаления дубликатов для tg_id: {tg_id}" - ) + logger.info(f"Объединено {len(all_lines)} строк после фильтрации и удаления дубликатов для tg_id: {tg_id}") return list(all_lines) + transition_date = datetime.strptime(TRANSITION_DATE_STR, "%Y-%m-%d %H:%M:%S") transition_timestamp_ms = int(transition_date.timestamp() * 1000) transition_timestamp_ms_adjusted = transition_timestamp_ms - (3 * 60 * 60 * 1000) -logger.info( - f"Время перехода (с поправкой на часовой пояс): {transition_timestamp_ms_adjusted}" -) +logger.info(f"Время перехода (с поправкой на часовой пояс): {transition_timestamp_ms_adjusted}") + async def handle_old_subscription(request): email = request.match_info.get("email") @@ -95,9 +92,7 @@ async def handle_old_subscription(request): await init_db_pool() async with db_pool.acquire() as conn: - key_info = await conn.fetchrow( - "SELECT created_at, server_id FROM keys WHERE email = $1", email - ) + key_info = await get_key_details(email, conn) if not key_info: logger.warning(f"Клиент с email {email} не найден в базе.") @@ -115,14 +110,10 @@ async def handle_old_subscription(request): status=400, ) - logger.info( - f"Значение created_at для клиента с email {email}: {created_at_ms}, кластер: {cluster_name}" - ) + logger.info(f"Значение created_at для клиента с email {email}: {created_at_ms}, кластер: {cluster_name}") created_at_datetime = datetime.utcfromtimestamp(created_at_ms / 1000) - logger.info( - f"Время создания клиента в формате datetime (UTC): {created_at_datetime}" - ) + logger.info(f"Время создания клиента в формате datetime (UTC): {created_at_datetime}") if created_at_ms >= transition_timestamp_ms_adjusted: logger.info(f"Клиент с email {email} является новым.") @@ -131,27 +122,22 @@ async def handle_old_subscription(request): status=400, ) - servers = await get_servers_from_db() + servers = await get_servers() cluster_servers = servers.get(cluster_name, []) logger.info(f"Сервера в кластере: {cluster_servers}") - urls = [ - f"{server['subscription_url']}/{email}" for server in cluster_servers - ] + urls = [f"{server['subscription_url']}/{email}" for server in cluster_servers] combined_subscriptions = await combine_unique_lines(urls, email, "") - base64_encoded = base64.b64encode( - "\n".join(combined_subscriptions).encode("utf-8") - ).decode("utf-8") + base64_encoded = base64.b64encode("\n".join(combined_subscriptions).encode("utf-8")).decode("utf-8") encoded_project_name = f"{PROJECT_NAME} - {SUB_MESSAGE}" headers = { "Content-Type": "text/plain; charset=utf-8", "Content-Disposition": "inline", "profile-update-interval": "7", - "profile-title": "base64:" - + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"), + "profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"), } logger.info(f"Возвращаем объединенные подписки для email: {email}") @@ -175,9 +161,7 @@ async def handle_new_subscription(request): await init_db_pool() async with db_pool.acquire() as conn: - client_data = await conn.fetchrow( - "SELECT tg_id, server_id FROM keys WHERE email = $1", email - ) + client_data = await get_key_details(email, conn) if not client_data: logger.warning(f"Клиент с email {email} не найден в базе.") @@ -196,21 +180,17 @@ async def handle_new_subscription(request): status=403, ) - servers = await get_servers_from_db() + servers = await get_servers() cluster_servers = servers.get(cluster_name, []) - urls = [ - f"{server['subscription_url']}/{email}" for server in cluster_servers - ] + urls = [f"{server['subscription_url']}/{email}" for server in cluster_servers] query_string = request.query_string logger.info(f"Извлечен query string: {query_string}") combined_subscriptions = await combine_unique_lines(urls, tg_id, query_string) - base64_encoded = base64.b64encode( - "\n".join(combined_subscriptions).encode("utf-8") - ).decode("utf-8") + base64_encoded = base64.b64encode("\n".join(combined_subscriptions).encode("utf-8")).decode("utf-8") encoded_project_name = f"{PROJECT_NAME} - {SUB_MESSAGE}" @@ -218,8 +198,7 @@ async def handle_new_subscription(request): "Content-Type": "text/plain; charset=utf-8", "Content-Disposition": "inline", "profile-update-interval": "7", - "profile-title": "base64:" - + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"), + "profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"), } logger.info(f"Возвращаем объединенные подписки для email: {email}") diff --git a/handlers/keys/trial_key.py b/handlers/keys/trial_key.py index 97821fb0..44745102 100644 --- a/handlers/keys/trial_key.py +++ b/handlers/keys/trial_key.py @@ -6,9 +6,9 @@ from typing import Any import pytz from py3xui import AsyncApi -from client import add_client +from client import ClientConfig, add_client from config import ADMIN_PASSWORD, ADMIN_USERNAME, LIMIT_IP, PUBLIC_LINK, SUPERNODE, TOTAL_GB, TRIAL_TIME -from database import get_servers_from_db, store_key, use_trial +from database import get_servers, get_trial, store_key, update_trial from handlers.texts import INSTRUCTIONS from handlers.utils import generate_random_email, get_least_loaded_cluster from logger import logger @@ -16,9 +16,7 @@ from logger import logger async def create_trial_key(tg_id: int, session: Any): try: - trial_status = await session.fetchval( - "SELECT trial FROM connections WHERE tg_id = $1", tg_id - ) + trial_status = await get_trial(tg_id, session) if trial_status == 1: return {"error": "Вы уже использовали пробную версию."} except Exception as e: @@ -35,7 +33,7 @@ async def create_trial_key(tg_id: int, session: Any): expiry_time = current_time + timedelta(days=TRIAL_TIME) expiry_timestamp = int(expiry_time.timestamp() * 1000) - clusters = await get_servers_from_db() + clusters = await get_servers(session) least_loaded_cluster = await get_least_loaded_cluster() if least_loaded_cluster not in clusters: raise ValueError(f"Кластер {least_loaded_cluster} не найден в базе данных.") @@ -58,16 +56,18 @@ async def create_trial_key(tg_id: int, session: Any): username=ADMIN_USERNAME, password=ADMIN_PASSWORD, ), - client_id, - email, - tg_id, - limit_ip=LIMIT_IP, - total_gb=TOTAL_GB, - expiry_time=expiry_timestamp, - enable=True, - flow="xtls-rprx-vision", - inbound_id=int(server_info["inbound_id"]), - sub_id=base_email + ClientConfig( + client_id=client_id, + email=email, + tg_id=tg_id, + limit_ip=LIMIT_IP, + total_gb=TOTAL_GB, + expiry_time=expiry_timestamp, + enable=True, + flow="xtls-rprx-vision", + inbound_id=int(server_info["inbound_id"]), + sub_id=base_email, + ), ) ) @@ -83,5 +83,5 @@ async def create_trial_key(tg_id: int, session: Any): session=session, ) - await use_trial(tg_id, session) + await update_trial(tg_id, 1, session) return result diff --git a/handlers/notifications.py b/handlers/notifications.py index 367fd0c6..b051e32a 100644 --- a/handlers/notifications.py +++ b/handlers/notifications.py @@ -2,6 +2,7 @@ import asyncio import os from datetime import datetime, timedelta +import aiofiles import asyncpg import pytz from aiogram import Bot, Router, types @@ -9,7 +10,6 @@ from aiogram.exceptions import TelegramForbiddenError from aiogram.types import BufferedInputFile from aiogram.utils.keyboard import InlineKeyboardBuilder from py3xui import AsyncApi -from .utils import format_time_until_deletion from config import ( ADMIN_PASSWORD, @@ -17,20 +17,20 @@ from config import ( AUTO_DELETE_EXPIRED_KEYS, AUTO_RENEW_KEYS, DATABASE_URL, + DELETE_KEYS_DELAY, DEV_MODE, EXPIRED_KEYS_CHECK_INTERVAL, RENEWAL_PLANS, TOTAL_GB, TRIAL_TIME, - DELETE_KEYS_DELAY, ) from database import ( - add_blocked_user, add_notification, check_notification_time, + create_blocked_user, delete_key, get_balance, - get_servers_from_db, + get_servers, update_balance, update_key_expiry, ) @@ -38,29 +38,10 @@ from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluste from handlers.texts import KEY_EXPIRY_10H, KEY_EXPIRY_24H, KEY_RENEWED from logger import logger +from .utils import format_time_until_deletion + router = Router() -async def check_users_and_update_blocked(bot: Bot): - conn = None - try: - conn = await asyncpg.connect(DATABASE_URL) - users = await conn.fetch("SELECT tg_id FROM users") - - for user in users: - try: - await bot.send_chat_action(user['tg_id'], "typing") - except (TelegramForbiddenError,Exception): - await conn.execute( - "INSERT INTO blocked_users (tg_id) VALUES ($1) ON CONFLICT (tg_id) DO NOTHING", - user['tg_id'] - ) - logger.info(f"User {user['tg_id']} added to blocked_users") - except Exception as e: - logger.error(f"Error in check_users_and_update_blocked: {e}") - finally: - if conn: - await conn.close() - async def periodic_expired_keys_check(bot: Bot): """Периодическая проверка истекших ключей с кастомным интервалом.""" @@ -80,7 +61,6 @@ async def periodic_expired_keys_check(bot: Bot): await asyncio.sleep(EXPIRED_KEYS_CHECK_INTERVAL) - async def notify_expiring_keys(bot: Bot): conn = None try: @@ -95,8 +75,6 @@ async def notify_expiring_keys(bot: Bot): await notify_inactive_trial_users(bot, conn) await asyncio.sleep(0.5) - await check_online_users() - await asyncio.sleep(0.5) await notify_10h_keys(bot, conn, current_time, threshold_time_10h) await asyncio.sleep(0.5) await notify_24h_keys(bot, conn, current_time, threshold_time_24h) @@ -110,24 +88,6 @@ async def notify_expiring_keys(bot: Bot): logger.info("Соединение с базой данных закрыто.") - -async def is_bot_blocked(bot: Bot, chat_id: int) -> bool: - if DEV_MODE: - return False - try: - member = await bot.get_chat_member(chat_id, bot.id) - blocked = member.status == "left" - logger.info( - f"Статус бота для пользователя {chat_id}: {'заблокирован' if blocked else 'активен'}" - ) - return blocked - except Exception as e: - logger.warning( - f"Не удалось проверить статус бота для пользователя {chat_id}: {e}" - ) - return False - - async def notify_10h_keys( bot: Bot, conn: asyncpg.Connection, @@ -163,7 +123,11 @@ async def process_10h_record(record, bot, conn): time_left = expiry_date - current_date days_left_message = ( - "Ключ истек" if time_left.total_seconds() <= 0 else f"{time_left.days}" if time_left.days > 0 else f"{time_left.seconds // 3600}" + "Ключ истек" + if time_left.total_seconds() <= 0 + else f"{time_left.days}" + if time_left.days > 0 + else f"{time_left.seconds // 3600}" ) message = KEY_EXPIRY_10H.format( @@ -177,11 +141,11 @@ async def process_10h_record(record, bot, conn): if AUTO_RENEW_KEYS and balance >= RENEWAL_PLANS["1"]["price"]: try: - await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"]) + await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"], conn) new_expiry_time = int((datetime.utcnow() + timedelta(days=30)).timestamp() * 1000) - await update_key_expiry(record["client_id"], new_expiry_time) + await update_key_expiry(record["client_id"], new_expiry_time, conn) - servers = await get_servers_from_db() + servers = await get_servers(conn) for cluster_id in servers: await renew_key_in_cluster(cluster_id, email, record["client_id"], new_expiry_time, TOTAL_GB) logger.info(f"Ключ для пользователя {tg_id} успешно продлен в кластере {cluster_id}.") @@ -194,10 +158,11 @@ async def process_10h_record(record, bot, conn): ) if os.path.isfile(image_path): - with open(image_path, "rb") as image_file: + async with aiofiles.open(image_path, "rb") as image_file: + image_data = await image_file.read() await bot.send_photo( tg_id, - photo=BufferedInputFile(image_file.read(), filename="notify_10h.jpg"), + photo=BufferedInputFile(image_data, filename="notify_10h.jpg"), caption=KEY_RENEWED.format(email=email), reply_markup=keyboard, ) @@ -212,7 +177,6 @@ async def process_10h_record(record, bot, conn): await send_renewal_notification(bot, tg_id, email, message, conn, record["client_id"], "notified") - async def notify_24h_keys( bot: Bot, conn: asyncpg.Connection, @@ -250,7 +214,11 @@ async def process_24h_record(record, bot, conn): time_left = expiry_date - current_date days_left_message = ( - "Ключ истек" if time_left.total_seconds() <= 0 else f"{time_left.days}" if time_left.days > 0 else f"{time_left.seconds // 3600}" + "Ключ истек" + if time_left.total_seconds() <= 0 + else f"{time_left.days}" + if time_left.days > 0 + else f"{time_left.seconds // 3600}" ) message_24h = KEY_EXPIRY_24H.format( @@ -263,11 +231,11 @@ async def process_24h_record(record, bot, conn): if AUTO_RENEW_KEYS and balance >= RENEWAL_PLANS["1"]["price"]: try: - await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"]) + await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"], conn) new_expiry_time = int((datetime.utcnow() + timedelta(days=30)).timestamp() * 1000) - await update_key_expiry(record["client_id"], new_expiry_time) + await update_key_expiry(record["client_id"], new_expiry_time, conn) - servers = await get_servers_from_db() + servers = await get_servers(conn) for cluster_id in servers: await renew_key_in_cluster(cluster_id, email, record["client_id"], new_expiry_time, TOTAL_GB) logger.info(f"Ключ для пользователя {tg_id} успешно продлен в кластере {cluster_id}.") @@ -280,10 +248,11 @@ async def process_24h_record(record, bot, conn): ) if os.path.isfile(image_path): - with open(image_path, "rb") as image_file: + async with aiofiles.open(image_path, "rb") as image_file: + image_data = await image_file.read() await bot.send_photo( tg_id, - photo=BufferedInputFile(image_file.read(), filename="notify_24h.jpg"), + photo=BufferedInputFile(image_data, filename="notify_24h.jpg"), caption=KEY_RENEWED.format(email=email), reply_markup=keyboard, ) @@ -308,10 +277,11 @@ async def send_renewal_notification(bot, tg_id, email, message, conn, client_id, image_path = os.path.join("img", "notify_24h.jpg") if os.path.isfile(image_path): - with open(image_path, "rb") as image_file: + async with aiofiles.open(image_path, "rb") as image_file: + image_data = await image_file.read() await bot.send_photo( tg_id, - photo=BufferedInputFile(image_file.read(), filename="notify_24h.jpg"), + photo=BufferedInputFile(image_data, filename="notify_24h.jpg"), caption=message, reply_markup=keyboard.as_markup(), ) @@ -320,7 +290,7 @@ async def send_renewal_notification(bot, tg_id, email, message, conn, client_id, logger.info(f"Уведомление отправлено пользователю {tg_id}.") - await conn.execute(f"UPDATE keys SET {flag} = TRUE WHERE client_id = $1", client_id) + await conn.execute("UPDATE keys SET notified_24h = $1 WHERE client_id = $2", flag, client_id) except Exception as e: logger.error(f"Ошибка при отправке уведомления пользователю {tg_id}: {e}") @@ -348,17 +318,10 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection): username = user["username"] first_name = user["first_name"] last_name = user["last_name"] - display_name = ( - username - or first_name - or last_name - or "Пользователь" - ) + display_name = username or first_name or last_name or "Пользователь" try: - can_notify = await check_notification_time( - tg_id, "inactive_trial", hours=24, session=conn - ) + can_notify = await check_notification_time(tg_id, "inactive_trial", hours=24, session=conn) if can_notify: builder = InlineKeyboardBuilder() @@ -368,11 +331,7 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection): callback_data="create_key", ) ) - builder.row( - types.InlineKeyboardButton( - text="👤 Личный кабинет", callback_data="profile" - ) - ) + builder.row(types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) keyboard = builder.as_markup() message = ( @@ -384,26 +343,21 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection): try: await bot.send_message(tg_id, message, reply_markup=keyboard) - logger.info( - f"Отправлено уведомление неактивному пользователю {tg_id}." - ) + logger.info(f"Отправлено уведомление неактивному пользователю {tg_id}.") await add_notification(tg_id, "inactive_trial", session=conn) except TelegramForbiddenError: - logger.warning( - f"Бот заблокирован пользователем {tg_id}. Добавляем в blocked_users." - ) - await add_blocked_user(tg_id, conn) + logger.warning(f"Бот заблокирован пользователем {tg_id}. Добавляем в blocked_users.") + await create_blocked_user(tg_id, conn) except Exception as e: - logger.error( - f"Ошибка при отправке уведомления пользователю {tg_id}: {e}" - ) + logger.error(f"Ошибка при отправке уведомления пользователю {tg_id}: {e}") except Exception as e: logger.error(f"Ошибка при обработке пользователя {tg_id}: {e}") await asyncio.sleep(1) + async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: float): logger.info("Проверка подписок, срок действия которых скоро истекает...") @@ -440,19 +394,17 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: for record in expired_keys: try: await delete_key_from_cluster( - cluster_id=record["server_id"], - email=record["email"], - client_id=record["client_id"] + cluster_id=record["server_id"], email=record["email"], client_id=record["client_id"] ) - await conn.execute( - "DELETE FROM keys WHERE client_id = $1", - record["client_id"] + await delete_key(record["client_id"], conn) + logger.info( + f"Ключ {record['client_id']} удалён" + + (f" после задержки {DELETE_KEYS_DELAY} сек." if DELETE_KEYS_DELAY > 0 else "") ) - logger.info(f"Ключ {record['client_id']} удалён" + - (f" после задержки {DELETE_KEYS_DELAY} сек." if DELETE_KEYS_DELAY > 0 else "")) except Exception as e: logger.error(f"Ошибка при удалении ключа {record['client_id']}: {e}") + async def process_key(record, bot, conn): tg_id = record["tg_id"] client_id = record["client_id"] @@ -474,26 +426,20 @@ async def process_key(record, bot, conn): keyboard = InlineKeyboardBuilder() if DELETE_KEYS_DELAY > 0: - keyboard.row(types.InlineKeyboardButton( - text="🔄 Продлить", - callback_data=f"renew_key|{email}" - )) - - keyboard.row(types.InlineKeyboardButton( - text="👤 Личный кабинет", - callback_data="profile" - )) + keyboard.row(types.InlineKeyboardButton(text="🔄 Продлить", callback_data=f"renew_key|{email}")) + + keyboard.row(types.InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) image_path = os.path.join("img", "notify_expired.jpg") try: if AUTO_RENEW_KEYS and balance >= RENEWAL_PLANS["1"]["price"]: - await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"]) + await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"], conn) new_expiry_time = int((datetime.now(moscow_tz) + timedelta(days=30)).timestamp() * 1000) - await update_key_expiry(client_id, new_expiry_time) + await update_key_expiry(client_id, new_expiry_time, conn) - servers = await get_servers_from_db() + servers = await get_servers(conn) for cluster_id in servers: await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, TOTAL_GB) @@ -511,10 +457,11 @@ async def process_key(record, bot, conn): try: if os.path.isfile(image_path): - with open(image_path, "rb") as image_file: + async with aiofiles.open(image_path, "rb") as image_file: + image_data = await image_file.read() await bot.send_photo( tg_id, - photo=BufferedInputFile(image_file.read(), filename="notify_expired.jpg"), + photo=BufferedInputFile(image_data, filename="notify_expired.jpg"), caption=KEY_RENEWED.format(email=email), reply_markup=keyboard.as_markup(), ) @@ -531,7 +478,7 @@ async def process_key(record, bot, conn): time_since_expiry = current_time_utc - expiry_time if time_since_expiry <= EXPIRED_KEYS_CHECK_INTERVAL * 1000: message_expired = f"Ваша подписка {email} истекла. Пополните баланс для продления." - + if DELETE_KEYS_DELAY > 0: time_until_deletion = format_time_until_deletion(DELETE_KEYS_DELAY) if time_until_deletion != "0 минут": @@ -539,19 +486,16 @@ async def process_key(record, bot, conn): try: if os.path.isfile(image_path): - with open(image_path, "rb") as image_file: + async with aiofiles.open(image_path, "rb") as image_file: + image_data = await image_file.read() await bot.send_photo( tg_id, - photo=BufferedInputFile(image_file.read(), filename="notify_expired.jpg"), + photo=BufferedInputFile(image_data, filename="notify_expired.jpg"), caption=message_expired, reply_markup=keyboard.as_markup(), ) else: - await bot.send_message( - tg_id, - text=message_expired, - reply_markup=keyboard.as_markup() - ) + await bot.send_message(tg_id, text=message_expired, reply_markup=keyboard.as_markup()) logger.info(f"Уведомление об истечении подписки отправлено пользователю {tg_id}.") except Exception as e: logger.error(f"Не удалось отправить уведомление об истечении клиенту {tg_id}: {e}") @@ -560,9 +504,9 @@ async def process_key(record, bot, conn): if AUTO_DELETE_EXPIRED_KEYS: current_time = int(datetime.utcnow().timestamp() * 1000) - + if DELETE_KEYS_DELAY == 0 or current_time >= expiry_time + (DELETE_KEYS_DELAY * 1000): - servers = await get_servers_from_db() + servers = await get_servers(conn) for cluster_id in servers: try: @@ -592,22 +536,3 @@ async def process_key(record, bot, conn): except Exception as e: logger.error(f"Ошибка при обработке ключа для клиента {tg_id}: {e}") - -async def check_online_users(): - servers = await get_servers_from_db() - - for cluster_id, cluster in servers.items(): - for server_id, server in enumerate(cluster): - xui = AsyncApi( - server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD - ) - await xui.login() - try: - online_users = len(await xui.client.online()) - logger.info( - f"Сервер '{server['server_name']}' доступен, текущее количество активных пользователей: {online_users}." - ) - except Exception as e: - logger.error( - f"Не удалось проверить пользователей на сервере {server_id}: {e}" - ) diff --git a/handlers/pay.py b/handlers/pay.py index 2b7c753c..24dd97a1 100644 --- a/handlers/pay.py +++ b/handlers/pay.py @@ -54,15 +54,9 @@ async def handle_pay(callback_query: CallbackQuery): ) ) - builder.row( - InlineKeyboardButton( - text="🎟️ Активировать купон", callback_data="activate_coupon" - ) - ) + builder.row(InlineKeyboardButton(text="🎟️ Активировать купон", callback_data="activate_coupon")) if DONATIONS_ENABLE: - builder.row( - InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate") - ) + builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate")) builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) diff --git a/handlers/payments/robokassa_pay.py b/handlers/payments/robokassa_pay.py index f9cb7a6e..5daeee61 100644 --- a/handlers/payments/robokassa_pay.py +++ b/handlers/payments/robokassa_pay.py @@ -52,9 +52,7 @@ if ROBOKASSA_ENABLE: def generate_payment_link(amount, inv_id, description, tg_id): """Генерация ссылки на оплату.""" - logger.debug( - f"Generating payment link for amount: {amount}, inv_id: {inv_id}, description: {description}" - ) + logger.debug(f"Generating payment link for amount: {amount}, inv_id: {inv_id}, description: {description}") payment_link = robokassa._payment.link.generate_by_script( out_sum=amount, inv_id=inv_id, @@ -66,9 +64,7 @@ def generate_payment_link(amount, inv_id, description, tg_id): @router.callback_query(F.data == "pay_robokassa") -async def process_callback_pay_robokassa( - callback_query: types.CallbackQuery, state: FSMContext, session: Any -): +async def process_callback_pay_robokassa(callback_query: types.CallbackQuery, state: FSMContext, session: Any): tg_id = callback_query.message.chat.id logger.info(f"User {tg_id} initiated Robokassa payment.") @@ -78,18 +74,18 @@ async def process_callback_pay_robokassa( builder.row( InlineKeyboardButton( text=PAYMENT_OPTIONS[i]["text"], - callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i]["callback_data"]}', + callback_data=f"robokassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}", ), InlineKeyboardButton( text=PAYMENT_OPTIONS[i + 1]["text"], - callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i + 1]["callback_data"]}', + callback_data=f"robokassa_amount|{PAYMENT_OPTIONS[i + 1]['callback_data']}", ), ) else: builder.row( InlineKeyboardButton( text=PAYMENT_OPTIONS[i]["text"], - callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i]["callback_data"]}', + callback_data=f"robokassa_amount|{PAYMENT_OPTIONS[i]['callback_data']}", ) ) builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="pay")) @@ -111,9 +107,7 @@ async def process_callback_pay_robokassa( @router.callback_query(F.data.startswith("robokassa_amount|")) -async def process_amount_selection( - callback_query: types.CallbackQuery, state: FSMContext -): +async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext): logger.info(f"Получены данные callback_data: {callback_query.data}") data = callback_query.data.split("|") @@ -167,9 +161,7 @@ async def robokassa_webhook(request): shp_id = params.get("shp_id") signature_value = params.get("SignatureValue") - logger.info( - f"OutSum: {amount}, InvId: {inv_id}, shp_id: {shp_id}, SignatureValue: {signature_value}" - ) + logger.info(f"OutSum: {amount}, InvId: {inv_id}, shp_id: {shp_id}, SignatureValue: {signature_value}") if not check_payment_signature(params): logger.error("Неверная подпись или данные запроса.") @@ -208,9 +200,7 @@ def check_payment_signature(params): logger.info(f"Signature string before hashing: {signature_string}") - expected_signature = ( - hashlib.md5(signature_string.encode("utf-8")).hexdigest().upper() - ) + expected_signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest().upper() logger.info(f"Expected signature: {expected_signature}") logger.info(f"Received signature: {signature_value}") @@ -219,27 +209,25 @@ def check_payment_signature(params): @router.callback_query(F.data == "enter_custom_amount_robokassa") -async def process_custom_amount_selection( - callback_query: types.CallbackQuery, state: FSMContext -): +async def process_custom_amount_selection(callback_query: types.CallbackQuery, state: FSMContext): tg_id = callback_query.message.chat.id logger.info(f"User {tg_id} chose to enter a custom amount.") builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="pay_robokassa")) + builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="pay_robokassa")) await callback_query.message.answer( "Пожалуйста, введите сумму пополнения.", reply_markup=builder.as_markup(), ) - await state.set_state( - ReplenishBalanceState.waiting_for_payment_confirmation_robokassa - ) + await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_robokassa) @router.message(ReplenishBalanceState.waiting_for_payment_confirmation_robokassa) -async def handle_custom_amount_input(message: types.Message | types.CallbackQuery, state: FSMContext = None, session: Any = None): +async def handle_custom_amount_input( + message: types.Message | types.CallbackQuery, state: FSMContext = None, session: Any = None +): if isinstance(message, types.CallbackQuery): tg_id = message.message.chat.id else: @@ -249,7 +237,6 @@ async def handle_custom_amount_input(message: types.Message | types.CallbackQuer inv_id = 0 try: - conn = await asyncpg.connect(DATABASE_URL) user_data = await get_temporary_data(conn, tg_id) await conn.close() @@ -277,9 +264,13 @@ async def handle_custom_amount_input(message: types.Message | types.CallbackQuer ) if state_type == "waiting_for_payment": - message_text = f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:" + message_text = ( + f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:" + ) elif state_type == "waiting_for_renewal_payment": - message_text = f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:" + message_text = ( + f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:" + ) else: await message.answer("Некорректное состояние данных. Попробуйте снова.") return diff --git a/handlers/profile.py b/handlers/profile.py index dfd5fa23..64bbe141 100644 --- a/handlers/profile.py +++ b/handlers/profile.py @@ -1,6 +1,7 @@ import os from typing import Any +import aiofiles import asyncpg from aiogram import F, Router, types from aiogram.fsm.context import FSMContext @@ -8,7 +9,7 @@ from aiogram.types import BufferedInputFile, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder from config import DATABASE_URL, NEWS_MESSAGE, RENEWAL_PLANS -from database import get_balance, get_key_count, get_referral_stats, get_trial +from database import get_balance, get_key_count, get_last_payments, get_referral_stats, get_trial from handlers.buttons.profile import ( ADD_SUB, BALANCE, @@ -51,9 +52,7 @@ async def process_callback_view_profile( try: trial_status = await get_trial(chat_id, conn) - profile_message = profile_message_send( - username, chat_id, int(balance), key_count - ) + profile_message = profile_message_send(username, chat_id, int(balance), key_count) if key_count == 0: profile_message += "\n
🔧 Нажмите кнопку ➕ Подписка, чтобы настроить VPN-подключение
" @@ -81,22 +80,21 @@ async def process_callback_view_profile( InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions"), ) if admin: - builder.row( - InlineKeyboardButton(text="🔧 Администратор", callback_data="admin") - ) + builder.row(InlineKeyboardButton(text="🔧 Администратор", callback_data="admin")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="start")) if os.path.isfile(image_path): - with open(image_path, "rb") as image_file: + async with aiofiles.open(image_path, "rb") as image_file: + image_data = await image_file.read() if is_callback: await callback_query_or_message.message.answer_photo( - photo=BufferedInputFile(image_file.read(), filename="pic.jpg"), + photo=BufferedInputFile(image_data, filename="pic.jpg"), caption=profile_message, reply_markup=builder.as_markup(), ) else: await callback_query_or_message.answer_photo( - photo=BufferedInputFile(image_file.read(), filename="pic.jpg"), + photo=BufferedInputFile(image_data, filename="pic.jpg"), caption=profile_message, reply_markup=builder.as_markup(), ) @@ -122,10 +120,8 @@ async def balance_handler(callback_query: types.CallbackQuery): builder.row(InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) - await callback_query.message.answer( - "💰 Управление балансом:", - reply_markup=builder.as_markup() - ) + await callback_query.message.answer("💰 Управление балансом:", reply_markup=builder.as_markup()) + @router.callback_query(F.data == "balance_history") async def balance_history_handler(callback_query: types.CallbackQuery, session: Any): @@ -133,22 +129,15 @@ async def balance_history_handler(callback_query: types.CallbackQuery, session: builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) - query = """ - SELECT amount, payment_system, status, created_at - FROM payments - WHERE tg_id = $1 - ORDER BY created_at DESC - LIMIT 3 - """ - records = await session.fetch(query, callback_query.from_user.id) + records = await get_last_payments(callback_query.from_user.id, session) if records: history_text = "📊 Последние 3 операции с балансом:\n\n" 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') + amount = record["amount"] + payment_system = record["payment_system"] + status = record["status"] + date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S") history_text += ( f"Сумма: {amount}₽\n" f"Способ оплаты: {payment_system}\n" @@ -158,11 +147,7 @@ async def balance_history_handler(callback_query: types.CallbackQuery, session: else: history_text = "❌ У вас пока нет операций с балансом." - await callback_query.message.answer( - history_text, - reply_markup=builder.as_markup() - ) - + await callback_query.message.answer(history_text, reply_markup=builder.as_markup()) @router.message(F.text == "/tariffs") @@ -183,9 +168,10 @@ async def view_tariffs_handler(callback_query: types.CallbackQuery): ) if os.path.isfile(image_path): - with open(image_path, "rb") as image_file: + async with aiofiles.open(image_path, "rb") as image_file: + image_data = await image_file.read() await callback_query.message.answer_photo( - photo=BufferedInputFile(image_file.read(), filename="tariffs.jpg"), + photo=BufferedInputFile(image_data, filename="tariffs.jpg"), caption=tariffs_message, reply_markup=builder.as_markup(), ) @@ -210,9 +196,10 @@ async def invite_handler(callback_query: types.CallbackQuery): builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) if os.path.isfile(image_path): - with open(image_path, "rb") as image_file: + async with aiofiles.open(image_path, "rb") as image_file: + image_data = await image_file.read() await callback_query.message.answer_photo( - photo=BufferedInputFile(image_file.read(), filename="pic_invite.jpg"), + photo=BufferedInputFile(image_data, filename="pic_invite.jpg"), caption=invite_message, reply_markup=builder.as_markup(), ) diff --git a/handlers/start.py b/handlers/start.py index ad8d2c42..2eac8a4b 100644 --- a/handlers/start.py +++ b/handlers/start.py @@ -1,6 +1,7 @@ import os from typing import Any +import aiofiles from aiogram import F, Router from aiogram.filters import Command from aiogram.fsm.context import FSMContext @@ -13,6 +14,7 @@ from aiogram.types import ( from aiogram.utils.keyboard import InlineKeyboardBuilder from config import ( + CAPTCHA_ENABLE, CHANNEL_EXISTS, CHANNEL_URL, CONNECT_ANDROID, @@ -26,8 +28,10 @@ from database import ( add_connection, add_referral, check_connection_exists, + get_coupon_details, + get_referral_by_referred_id, get_trial, - use_trial, + update_trial, ) from handlers.buttons.add_subscribe import ( DOWNLOAD_ANDROID_BUTTON, @@ -37,6 +41,7 @@ from handlers.buttons.add_subscribe import ( PC_BUTTON, TV_BUTTON, ) +from handlers.captcha import generate_captcha from handlers.keys.key_management import create_key from handlers.keys.trial_key import create_trial_key from handlers.texts import INSTRUCTIONS_TRIAL, WELCOME_TEXT, get_about_vpn @@ -47,18 +52,24 @@ router = Router() @router.callback_query(F.data == "start") async def handle_start_callback_query( - callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool + callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool, captcha: bool = False ): - await start_command(callback_query.message, state, session, admin) + await start_command(callback_query.message, state, session, admin, captcha) @router.message(Command("start")) -async def start_command(message: Message, state: FSMContext, session: Any, admin: bool): +async def start_command(message: Message, state: FSMContext, session: Any, admin: bool, captcha: bool = True): """Обрабатывает команду /start, включает логику рефералов и подарков.""" logger.info(f"Вызвана функция start_command для пользователя {message.chat.id}") await state.clear() + # Проверка капчи, если включена + if CAPTCHA_ENABLE and captcha: + captcha = await generate_captcha(message, state) + await message.answer(text=captcha["text"], reply_markup=captcha["markup"]) + return + if message.text: try: connection_exists = await check_connection_exists(message.chat.id) @@ -66,9 +77,7 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin if not connection_exists: await add_connection(tg_id=message.chat.id, session=session) - logger.info( - f"Пользователь {message.chat.id} успешно добавлен в базу данных." - ) + logger.info(f"Пользователь {message.chat.id} успешно добавлен в базу данных.") if "gift_" in message.text: logger.info(f"Обнаружена ссылка на подарок: {message.text}") @@ -77,51 +86,29 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin recipient_tg_id = message.chat.id - gift_info = await session.fetchrow( - "SELECT * FROM gifts WHERE gift_id = $1 AND is_used = FALSE", - gift_id, - ) + gift_info = await get_coupon_details(gift_id, session) if gift_info is None: - logger.warning( - f"Подарок с ID {gift_id} уже был использован или не существует." - ) - await message.answer( - "Этот подарок уже был использован или не существует." - ) + logger.warning(f"Подарок с ID {gift_id} уже был использован или не существует.") + await message.answer("Этот подарок уже был использован или не существует.") return await show_start_menu(message, admin, session) if gift_info["sender_tg_id"] == recipient_tg_id: logger.warning( f"Пользователь {recipient_tg_id} попытался активировать подарок, который был отправлен им самим." ) - await message.answer( - "❌ Вы не можете получить подарок от самого себя." - ) + await message.answer("❌ Вы не можете получить подарок от самого себя.") return await show_start_menu(message, admin, session) - await session.execute( - """ - INSERT INTO connections (tg_id, balance, trial) - VALUES ($1, 0, 1) - ON CONFLICT (tg_id) DO UPDATE SET trial = 1 - """, - recipient_tg_id, - ) + await add_connection(tg_id=recipient_tg_id, session=session) selected_months = gift_info["selected_months"] expiry_time = gift_info["expiry_time"] expiry_time_naive = expiry_time.replace(tzinfo=None) - logger.info( - f"Подарок с ID {gift_id} успешно найден для пользователя {recipient_tg_id}." - ) + logger.info(f"Подарок с ID {gift_id} успешно найден для пользователя {recipient_tg_id}.") - await create_key( - recipient_tg_id, expiry_time_naive, state, session, message - ) - logger.info( - f"Ключ создан для пользователя {recipient_tg_id} на срок {selected_months} месяцев." - ) + await create_key(recipient_tg_id, expiry_time_naive, state, session, message) + logger.info(f"Ключ создан для пользователя {recipient_tg_id} на срок {selected_months} месяцев.") await session.execute( "UPDATE gifts SET is_used = TRUE, recipient_tg_id = $1 WHERE gift_id = $2", @@ -132,9 +119,7 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin await message.answer( f"🎉 Ваш подарок на {selected_months} {'месяц' if selected_months == 1 else 'месяца' if selected_months in [2, 3, 4] else 'месяцев'} активирован!" ) - logger.info( - f"Подарок на {selected_months} месяцев активирован для пользователя {recipient_tg_id}." - ) + logger.info(f"Подарок на {selected_months} месяцев активирован для пользователя {recipient_tg_id}.") return elif "referral_" in message.text: @@ -142,36 +127,23 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin referrer_tg_id = int(message.text.split("referral_")[1]) if connection_exists: - logger.info( - f"Пользователь {message.chat.id} уже зарегистрирован и не может стать рефералом." - ) - await message.answer( - "❌ Вы уже зарегистрированы и не можете использовать реферальную ссылку." - ) + logger.info(f"Пользователь {message.chat.id} уже зарегистрирован и не может стать рефералом.") + await message.answer("❌ Вы уже зарегистрированы и не можете использовать реферальную ссылку.") return await show_start_menu(message, admin, session) if referrer_tg_id == message.chat.id: - logger.warning( - f"Пользователь {message.chat.id} попытался стать рефералом самого себя." - ) - await message.answer( - "❌ Вы не можете быть рефералом самого себя." - ) + logger.warning(f"Пользователь {message.chat.id} попытался стать рефералом самого себя.") + await message.answer("❌ Вы не можете быть рефералом самого себя.") return await show_start_menu(message, admin, session) - existing_referral = await session.fetchrow( - "SELECT * FROM referrals WHERE referred_tg_id = $1", - message.chat.id, - ) + existing_referral = await get_referral_by_referred_id(message.chat.id, session) if existing_referral: logger.info(f"Реферал с ID {message.chat.id} уже существует.") return await show_start_menu(message, admin, session) await add_referral(message.chat.id, referrer_tg_id, session) - logger.info( - f"Реферал {message.chat.id} использовал ссылку от пользователя {referrer_tg_id}" - ) + logger.info(f"Реферал {message.chat.id} использовал ссылку от пользователя {referrer_tg_id}") return await show_start_menu(message, admin, session) except (ValueError, IndexError) as e: @@ -179,16 +151,12 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin return else: - logger.info( - f"Пользователь {message.chat.id} зашел без реферальной ссылки или подарка." - ) + logger.info(f"Пользователь {message.chat.id} зашел без реферальной ссылки или подарка.") await show_start_menu(message, admin, session) except (ValueError, IndexError) as e: - logger.error( - f"Ошибка при обработке сообщения пользователя {message.chat.id}: {e}" - ) + logger.error(f"Ошибка при обработке сообщения пользователя {message.chat.id}: {e}") await message.answer("❌ Произошла ошибка. Пожалуйста, попробуйте снова.") else: await show_start_menu(message, admin, session) @@ -203,33 +171,28 @@ async def show_start_menu(message: Message, admin: bool, session: Any): builder = InlineKeyboardBuilder() if trial_status == 0: - builder.row( - InlineKeyboardButton(text="🔗 Подключить VPN", callback_data="connect_vpn") - ) + builder.row(InlineKeyboardButton(text="🔗 Подключить VPN", callback_data="connect_vpn")) builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) if CHANNEL_EXISTS: builder.row( InlineKeyboardButton(text="📞 Поддержка", url=SUPPORT_CHAT_URL), - InlineKeyboardButton(text="📢 Канал", url=CHANNEL_URL) + InlineKeyboardButton(text="📢 Канал", url=CHANNEL_URL), ) else: - builder.row( - InlineKeyboardButton(text="📞 Поддержка", url=SUPPORT_CHAT_URL) - ) + builder.row(InlineKeyboardButton(text="📞 Поддержка", url=SUPPORT_CHAT_URL)) if admin: - builder.row( - InlineKeyboardButton(text="🔧 Администратор", callback_data="admin") - ) + builder.row(InlineKeyboardButton(text="🔧 Администратор", callback_data="admin")) builder.row(InlineKeyboardButton(text="🌐 О VPN", callback_data="about_vpn")) if os.path.isfile(image_path): - with open(image_path, "rb") as image_from_buffer: + async with aiofiles.open(image_path, "rb") as image_from_buffer: + image_data = await image_from_buffer.read() await message.answer_photo( - photo=BufferedInputFile(image_from_buffer.read(), filename="pic.jpg"), + photo=BufferedInputFile(image_data, filename="pic.jpg"), caption=WELCOME_TEXT, reply_markup=builder.as_markup(), ) @@ -249,7 +212,7 @@ async def handle_connect_vpn(callback_query: CallbackQuery, session: Any): if "error" in trial_key_info: await callback_query.message.answer(trial_key_info["error"]) else: - await use_trial(user_id, session) + await update_trial(user_id, 1, session) key_message = ( f"🔑 Ваш персональный ключ доступа:\n" @@ -268,24 +231,20 @@ async def handle_connect_vpn(callback_query: CallbackQuery, session: Any): builder.row( InlineKeyboardButton( text=IMPORT_IOS, - url=f'{CONNECT_IOS}{trial_key_info["key"]}', + url=f"{CONNECT_IOS}{trial_key_info['key']}", ), InlineKeyboardButton( text=IMPORT_ANDROID, - url=f'{CONNECT_ANDROID}{trial_key_info["key"]}', + url=f"{CONNECT_ANDROID}{trial_key_info['key']}", ), ) builder.row( InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"), InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"), ) - builder.row( - InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile") - ) + builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) - await callback_query.message.answer( - key_message, reply_markup=builder.as_markup() - ) + await callback_query.message.answer(key_message, reply_markup=builder.as_markup()) @router.callback_query(F.data == "about_vpn") @@ -293,9 +252,7 @@ async def handle_about_vpn(callback_query: CallbackQuery): builder = InlineKeyboardBuilder() if DONATIONS_ENABLE: - builder.row( - InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate") - ) + builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate")) builder.row( InlineKeyboardButton(text="📞 Техническая поддержка", url=SUPPORT_CHAT_URL), @@ -306,6 +263,4 @@ async def handle_about_vpn(callback_query: CallbackQuery): ) builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="start")) - await callback_query.message.answer( - get_about_vpn("3.2.3-minor"), reply_markup=builder.as_markup() - ) + await callback_query.message.answer(get_about_vpn("3.2.3-minor"), reply_markup=builder.as_markup()) diff --git a/handlers/user.py b/handlers/user.py new file mode 100644 index 00000000..44a190ec --- /dev/null +++ b/handlers/user.py @@ -0,0 +1,22 @@ +from typing import Any + +from aiogram import Router +from aiogram.filters.chat_member_updated import KICKED, MEMBER, ChatMemberUpdatedFilter +from aiogram.types import ChatMemberUpdated + +from database import create_blocked_user, delete_blocked_user +from logger import logger + +router = Router() + + +@router.my_chat_member(ChatMemberUpdatedFilter(member_status_changed=KICKED)) +async def user_blocked_bot(event: ChatMemberUpdated, session: Any): + logger.info(f"User {event.from_user.id} blocked the bot.") + await create_blocked_user(event.from_user.id, session) + + +@router.my_chat_member(ChatMemberUpdatedFilter(member_status_changed=MEMBER)) +async def user_unblocked_bot(event: ChatMemberUpdated, session: Any): + logger.info(f"User {event.from_user.id} unblocked the bot.") + await delete_blocked_user(event.from_user.id, session) diff --git a/handlers/utils.py b/handlers/utils.py index 34c28d8e..8b4c71a7 100644 --- a/handlers/utils.py +++ b/handlers/utils.py @@ -1,23 +1,23 @@ import json import random import re +import secrets +import string +from datetime import datetime, timedelta import aiohttp import asyncpg from bot import bot from config import DATABASE_URL -from database import get_servers_from_db +from database import get_all_keys, get_servers from logger import logger -from datetime import datetime, timedelta async def get_usd_rate(): try: async with aiohttp.ClientSession() as session: - async with session.get( - "https://www.cbr-xml-daily.ru/daily_json.js" - ) as response: + async with session.get("https://www.cbr-xml-daily.ru/daily_json.js") as response: if response.status == 200: data = await response.text() usd = float(json.loads(data)["Valute"]["USD"]["Value"]) @@ -52,7 +52,7 @@ def generate_random_email(length: int = 6) -> str: Returns: str: Сгенерированная случайная строка. """ - return "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=length)) + return "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(length)) if length > 0 else "" async def get_least_loaded_cluster() -> str: @@ -62,13 +62,13 @@ async def get_least_loaded_cluster() -> str: Returns: str: Идентификатор наименее загруженного кластера. """ - servers = await get_servers_from_db() + servers = await get_servers() cluster_loads: dict[str, int] = {cluster_id: 0 for cluster_id in servers.keys()} async with asyncpg.create_pool(DATABASE_URL) as pool: async with pool.acquire() as conn: - keys = await conn.fetch("SELECT server_id FROM keys") + keys = await get_all_keys(conn) for key in keys: cluster_id = key["server_id"] if cluster_id in cluster_loads: @@ -87,9 +87,7 @@ async def get_least_loaded_cluster() -> str: return least_loaded_cluster -async def handle_error( - tg_id: int, callback_query: object | None = None, message: str = "" -) -> None: +async def handle_error(tg_id: int, callback_query: object | None = None, message: str = "") -> None: """ Обрабатывает ошибку, отправляя сообщение пользователю. @@ -101,27 +99,26 @@ async def handle_error( try: if callback_query and hasattr(callback_query, "message"): try: - await bot.delete_message( - chat_id=tg_id, message_id=callback_query.message.message_id - ) + 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") + await bot.send_message(tg_id, message) except Exception as e: logger.error(f"Ошибка при обработке ошибки: {e}") + def format_time_until_deletion(seconds: int) -> str: if seconds <= 0: return "0 минут" - + days = seconds // (3600 * 24) hours = (seconds % (3600 * 24)) // 3600 minutes = (seconds % 3600 + 59) // 60 parts = [] - + if days > 0: if days == 1: parts.append(f"{days} день") @@ -129,7 +126,7 @@ def format_time_until_deletion(seconds: int) -> str: parts.append(f"{days} дня") else: parts.append(f"{days} дней") - + if hours > 0: if hours == 1: parts.append(f"{hours} час") @@ -137,7 +134,7 @@ def format_time_until_deletion(seconds: int) -> str: parts.append(f"{hours} часа") else: parts.append(f"{hours} часов") - + if minutes > 0 and days == 0: if minutes == 1: parts.append("1 минута") diff --git a/keyboards/admin/bans_kb.py b/keyboards/admin/bans_kb.py index 958d0a14..3c5c5145 100644 --- a/keyboards/admin/bans_kb.py +++ b/keyboards/admin/bans_kb.py @@ -1,21 +1,13 @@ from aiogram.types import InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder -from keyboards.admin.panel_kb import build_admin_back_btn, AdminPanelCallback +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_btn 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.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 index 41ebd8c3..c24d705c 100644 --- a/keyboards/admin/coupons_kb.py +++ b/keyboards/admin/coupons_kb.py @@ -11,17 +11,9 @@ class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"): 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() - ) + 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() @@ -32,12 +24,8 @@ def build_coupons_list_kb(coupons: list) -> InlineKeyboardMarkup: coupon_code = coupon["code"] builder.button( text=f"❌ Удалить {coupon_code}", - callback_data=AdminCouponDeleteCallback( - coupon_code=coupon_code - ).pack(), + callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code).pack(), ) - builder.row( - build_admin_back_btn("coupons") - ) + 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 index 9b85a216..2c23381d 100644 --- a/keyboards/admin/panel_kb.py +++ b/keyboards/admin/panel_kb.py @@ -1,5 +1,5 @@ from aiogram.filters.callback_data import CallbackData -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder @@ -9,76 +9,36 @@ class AdminPanelCallback(CallbackData, prefix="admin_panel"): 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.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() + 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" + 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.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.button(text="✅ Да, перезагрузить", callback_data=AdminPanelCallback(action="restart_confirm").pack()) + builder.row(build_admin_back_btn()) builder.adjust(1) return builder.as_markup() @@ -89,9 +49,7 @@ def build_admin_back_kb(action: str = "admin") -> InlineKeyboardMarkup: def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() - builder.row( - build_admin_btn(text, action) - ) + builder.row(build_admin_btn(text, action)) return builder.as_markup() diff --git a/keyboards/admin/sender_kb.py b/keyboards/admin/sender_kb.py index bfe9ad39..d0acfc59 100644 --- a/keyboards/admin/sender_kb.py +++ b/keyboards/admin/sender_kb.py @@ -11,26 +11,9 @@ class AdminSenderCallback(CallbackData, prefix="admin_sender"): 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.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 index d7dd239c..e857408b 100644 --- a/keyboards/admin/servers_kb.py +++ b/keyboards/admin/servers_kb.py @@ -16,19 +16,11 @@ def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup: for cluster_name in servers: builder.button( text=f"⚙️ {cluster_name}", - callback_data=AdminServerEditorCallback( - action="clusters_manage", - data=cluster_name - ).pack() + 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.button(text="➕ Добавить кластер", callback_data=AdminPanelCallback(action="clusters_add").pack()) + builder.row(build_admin_back_btn()) builder.adjust(1) return builder.as_markup() @@ -39,36 +31,22 @@ def build_manage_cluster_kb(cluster_servers, cluster_name) -> InlineKeyboardMark for server in cluster_servers: builder.button( text=f"🌍 {server['server_name']}", - callback_data=AdminServerEditorCallback( - action="servers_manage", - data=server["server_name"] - ).pack() + callback_data=AdminServerEditorCallback(action="servers_manage", data=server["server_name"]).pack(), ) builder.button( text="➕ Добавить сервер", - callback_data=AdminServerEditorCallback( - action="servers_add", - data=cluster_name - ).pack() + callback_data=AdminServerEditorCallback(action="servers_add", data=cluster_name).pack(), ) builder.button( text="🌐 Доступность серверов", - callback_data=AdminServerEditorCallback( - action="servers_availability", - data=cluster_name - ).pack() + 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") + callback_data=AdminServerEditorCallback(action="clusters_backup", data=cluster_name).pack(), ) + builder.row(build_admin_back_btn("servers")) builder.adjust(1) return builder.as_markup() @@ -76,18 +54,10 @@ def build_manage_cluster_kb(cluster_servers, cluster_name) -> InlineKeyboardMark def build_manage_server_kb(server_name: str, cluster_name: str) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button( - text="🗑️ Удалить", - callback_data=AdminServerEditorCallback( - action="servers_delete", - data=server_name - ).pack() + text="🗑️ Удалить", callback_data=AdminServerEditorCallback(action="servers_delete", data=server_name).pack() ) builder.button( - text="🔙 Назад", - callback_data=AdminServerEditorCallback( - action="clusters_manage", - data=cluster_name - ).pack() + text="🔙 Назад", callback_data=AdminServerEditorCallback(action="clusters_manage", data=cluster_name).pack() ) builder.adjust(1) return builder.as_markup() @@ -96,18 +66,10 @@ def build_manage_server_kb(server_name: str, cluster_name: str) -> InlineKeyboar def build_delete_server_kb(server_name: str) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button( - text="✅ Да", - callback_data=AdminServerEditorCallback( - action="servers_delete_confirm", - data=server_name - ).pack() + text="✅ Да", callback_data=AdminServerEditorCallback(action="servers_delete_confirm", data=server_name).pack() ) builder.button( - text="🔙 Назад", - callback_data=AdminServerEditorCallback( - action="servers_manage", - data=server_name - ).pack() + 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 index 4e1db626..b8046593 100644 --- a/keyboards/admin/stats_kb.py +++ b/keyboards/admin/stats_kb.py @@ -1,25 +1,19 @@ from aiogram.types import InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder -from keyboards.admin.panel_kb import build_admin_back_btn, AdminPanelCallback +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_btn def build_stats_kb() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() - builder.button( - text="🔄 Обновить", - callback_data=AdminPanelCallback(action="stats").pack() - ) + builder.button(text="🔄 Обновить", callback_data=AdminPanelCallback(action="stats").pack()) builder.button( text="📥 Выгрузить пользователей в CSV", - callback_data=AdminPanelCallback(action="stats_export_users_csv").pack() + 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() + 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 index 6eb8537c..54d056a9 100644 --- a/keyboards/admin/users_kb.py +++ b/keyboards/admin/users_kb.py @@ -32,47 +32,25 @@ def build_user_edit_kb(tg_id: int, key_records: list) -> InlineKeyboardMarkup: 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() + 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() + 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() + 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() + 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() + 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() @@ -81,10 +59,7 @@ 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() + callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(), ) return builder.as_markup() @@ -94,44 +69,23 @@ def build_users_balance_kb(tg_id: int) -> InlineKeyboardMarkup: 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() + 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() + 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() + 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() + 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) + 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() @@ -140,12 +94,7 @@ 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() + callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email, edit=True).pack(), ) return builder.as_markup() @@ -156,53 +105,25 @@ def build_users_key_expiry_kb(tg_id: int, email: str) -> InlineKeyboardMarkup: month = int(month) builder.button( text=f"+ {month} мес.", - callback_data=AdminUserKeyEditorCallback( - action="add", - tg_id=tg_id, - data=email, - month=month - ).pack() + 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() + 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() + 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() + 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() + 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() + 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() @@ -212,14 +133,9 @@ 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) + 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() @@ -227,12 +143,7 @@ def build_user_delete_kb(tg_id: int): 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() + text="🔙 Назад", callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack() ) builder.adjust(1) return builder.as_markup() @@ -243,30 +154,18 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup: builder.button( text="⏳ Время истечения", callback_data=AdminUserEditorCallback( - action="users_expiry_edit", - data=email, - tg_id=key_details["tg_id"] - ).pack() + 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() + 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) + 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() @@ -276,16 +175,10 @@ def build_key_delete_kb(tg_id: int, email: str) -> InlineKeyboardMarkup: builder.row( InlineKeyboardButton( text="✅ Да, удалить", - callback_data=AdminUserEditorCallback( - action="users_delete_key_confirm", - data=email, - tg_id=tg_id - ).pack() + callback_data=AdminUserEditorCallback(action="users_delete_key_confirm", data=email, tg_id=tg_id).pack(), ) ) - builder.row( - build_editor_back_btn(tg_id) - ) + builder.row(build_editor_back_btn(tg_id)) builder.adjust(1) return builder.as_markup() @@ -296,9 +189,7 @@ def build_editor_kb(tg_id: int, edit: bool = False) -> InlineKeyboardMarkup: 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) - ) + builder.row(build_editor_btn(text, tg_id, edit)) return builder.as_markup() @@ -308,10 +199,5 @@ def build_editor_back_btn(tg_id: int, edit: bool = False) -> InlineKeyboardButto 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() + text=text, callback_data=AdminUserEditorCallback(action="users_editor", tg_id=tg_id, edit=edit).pack() ) diff --git a/middlewares/__init__.py b/middlewares/__init__.py new file mode 100644 index 00000000..3257c3bd --- /dev/null +++ b/middlewares/__init__.py @@ -0,0 +1,25 @@ +from typing import Any + +from aiogram import Dispatcher + +from .admin import AdminMiddleware +from .delete import DeleteMessageMiddleware +from .loggings import LoggingMiddleware +from .session import SessionMiddleware +from .throttling import ThrottlingMiddleware +from .user import UserMiddleware + + +def register_middleware(dispatcher: Dispatcher) -> None: + middlewares = [ + AdminMiddleware(), + SessionMiddleware(), + DeleteMessageMiddleware(), + LoggingMiddleware(), + ThrottlingMiddleware(), + UserMiddleware(), + ] + + for middleware in middlewares: + dispatcher.message.outer_middleware(middleware) + dispatcher.callback_query.outer_middleware(middleware) diff --git a/middlewares/delete.py b/middlewares/delete.py index 29281507..b305c7bb 100644 --- a/middlewares/delete.py +++ b/middlewares/delete.py @@ -9,6 +9,7 @@ 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 +from logger import logger pass_callbacks = [ AdminPanelCallback, @@ -22,22 +23,17 @@ pass_callbacks = [ 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): - if ( - not event.text - or not event.text.startswith("/start") - ): + 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.bot.delete_message(event.chat.id, event.message_id - 1) + except Exception as e: + logger.error(e) await event.delete() if isinstance(event, CallbackQuery): diff --git a/middlewares/logging.py b/middlewares/loggings.py similarity index 100% rename from middlewares/logging.py rename to middlewares/loggings.py diff --git a/middlewares/database.py b/middlewares/session.py similarity index 93% rename from middlewares/database.py rename to middlewares/session.py index c9bf6906..a5d34441 100644 --- a/middlewares/database.py +++ b/middlewares/session.py @@ -8,7 +8,7 @@ from aiogram.types import TelegramObject from config import DATABASE_URL -class DatabaseMiddleware(BaseMiddleware): +class SessionMiddleware(BaseMiddleware): async def __call__( self, handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], diff --git a/middlewares/throttling.py b/middlewares/throttling.py index 2761920c..d1085dba 100644 --- a/middlewares/throttling.py +++ b/middlewares/throttling.py @@ -1,13 +1,31 @@ -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, MutableMapping from typing import Any from aiogram import BaseMiddleware -from aiogram.types import TelegramObject +from aiogram.dispatcher.flags import get_flag +from aiogram.types import TelegramObject, Update, User +from cachetools import TTLCache + +from logger import logger -class ThrottleMiddleware(BaseMiddleware): - def __init__(self, limit: int): - self.limit = limit +class ThrottlingMiddleware(BaseMiddleware): + def __init__( + self, + *, + default_key: str | None = "default", + default_ttl: float = 0.5, + **ttl_map: float, + ) -> None: + if default_key: + ttl_map[default_key] = default_ttl + + self.default_key = default_key + self.caches: dict[str, MutableMapping[int, None]] = {} + + for name, ttl in ttl_map.items(): + self.caches[name] = TTLCache(maxsize=10_000, ttl=ttl) + logger.debug("ThrottlingMiddleware initialized.") async def __call__( self, @@ -15,5 +33,32 @@ class ThrottleMiddleware(BaseMiddleware): event: TelegramObject, data: dict[str, Any], ) -> Any: - #todo + if not isinstance(event, Update): + logger.debug(f"Received event of type {type(event)}, skipping throttling.") + return await handler(event, data) + + if event.pre_checkout_query: + logger.debug("Pre-checkout query event, skipping throttling.") + return await handler(event, data) + + if event.message and event.message.successful_payment: + logger.debug("Successful payment event, skipping throttling.") + return await handler(event, data) + + user: User | None = data.get("event_from_user", None) + + if user is not None: + key = get_flag(data, "throttling_key", default=self.default_key) + + if key: + if user.id in self.caches[key]: + logger.warning(f"User {user.id} is being throttled with key: {key}") + return None + logger.debug( + f"User {user.id} is allowed to proceed, adding to cache with key: {key}", + ) + self.caches[key][user.id] = None + else: + logger.debug(f"No throttling key provided for user {user.id}, proceeding without throttle.") + return await handler(event, data) diff --git a/pyproject.toml b/pyproject.toml index e8e30a7a..634f9533 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [tool.ruff] line-length = 120 -target-version = "py310" +target-version = "py312" [tool.ruff.lint] select = ["E", "F", "W", "I", "N", "UP", "ANN", "ASYNC", "S", "BLE", "FBT", "B", "A", "C4", "DTZ", "T10", "ISC", "ICN", "G", "PIE"] -ignore = ["ANN101", "ANN102", "S101",'ANN201','ANN001','BLE001'] +ignore = ["ANN101", "ANN102", "S101",'ANN201','ANN001','BLE001','W291','ANN401','DTZ003','DTZ005','F401','FBT002','FBT001','FBT003','A005','E501','UP017','DTZ004','W293','ANN202','DTZ007'] exclude = [ ".git", "venv", @@ -14,7 +14,7 @@ exclude = [ [tool.ruff.format] quote-style = "double" -indent-style = "tab" +indent-style = "space" [tool.darker] src = ["."] diff --git a/requirements.txt b/requirements.txt index 3275ecfa..2d8df8b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,4 +32,6 @@ sqlalchemy robokassa ping3 ruff -pytz \ No newline at end of file +pytz +cachetools +babel \ No newline at end of file diff --git a/servers.py b/servers.py index eacfc9e9..e1f35d53 100644 --- a/servers.py +++ b/servers.py @@ -9,16 +9,14 @@ from ping3 import ping from bot import bot from config import ADMIN_ID, DATABASE_URL, PING_TIME -from database import get_servers_from_db +from database import check_unique_server_name, create_server, get_servers from logger import logger try: from config import CLUSTERS except ImportError: CLUSTERS = None - logger.warning( - "Переменная CLUSTERS не найдена в конфигурации. Добавьте сервера через админ-панель!" - ) + logger.warning("Переменная CLUSTERS не найдена в конфигурации. Добавьте сервера через админ-панель!") async def sync_servers_with_db(): @@ -27,9 +25,7 @@ async def sync_servers_with_db(): Если CLUSTERS не найден, синхронизация не будет выполнена. """ if CLUSTERS is None: - logger.info( - "Конфигурация CLUSTERS не найдена. Синхронизация не будет выполнена." - ) + logger.info("Конфигурация CLUSTERS не найдена. Синхронизация не будет выполнена.") return try: @@ -37,35 +33,21 @@ async def sync_servers_with_db(): logger.info("Подключение к базе данных для синхронизации серверов успешно.") for cluster_name, servers in CLUSTERS.items(): - for server_key, server_info in servers.items(): - exists = await conn.fetchval( - """ - SELECT 1 FROM servers - WHERE cluster_name = $1 AND server_name = $2 - """, - cluster_name, - server_info["name"], - ) + for _server_key, server_info in servers.items(): + exists = await check_unique_server_name(server_info["name"], conn, cluster_name) if not exists: - await conn.execute( - """ - INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id) - VALUES ($1, $2, $3, $4, $5) - """, - cluster_name, - server_info["name"], - server_info["API_URL"], - server_info["SUBSCRIPTION"], - server_info["INBOUND_ID"], - ) - logger.info( - f"Сервер {server_info['name']} из кластера {cluster_name} добавлен в базу данных." + await create_server( + cluster_name=cluster_name, + server_name=server_info["name"], + api_url=server_info["API_URL"], + subscription_url=server_info["SUBSCRIPTION"], + inbound_id=server_info["INBOUND_ID"], + session=conn, ) + logger.info(f"Сервер {server_info['name']} из кластера {cluster_name} добавлен в базу данных.") else: - logger.info( - f"Сервер {server_info['name']} из кластера {cluster_name} уже существует." - ) + logger.info(f"Сервер {server_info['name']} из кластера {cluster_name} уже существует.") except Exception as e: logger.error(f"Ошибка при синхронизации серверов: {e}") @@ -104,25 +86,14 @@ async def notify_admin(server_name: str): current_time = datetime.now() last_notification_time = last_notification_times.get(server_name) - if ( - last_notification_time - and current_time - last_notification_time < timedelta(minutes=3) - ): - logger.info( - f"Не отправляем уведомление для сервера {server_name}, так как прошло менее 3 минут." - ) + if last_notification_time and current_time - last_notification_time < timedelta(minutes=3): + logger.info(f"Не отправляем уведомление для сервера {server_name}, так как прошло менее 3 минут.") return - logger.info( - f"Отправка уведомлений администратору о недоступности сервера {server_name}..." - ) + logger.info(f"Отправка уведомлений администратору о недоступности сервера {server_name}...") builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="Управление сервером", callback_data=f"manage_server|{server_name}" - ) - ) + builder.row(InlineKeyboardButton(text="Управление сервером", callback_data=f"manage_server|{server_name}")) for admin_id in ADMIN_ID: await bot.send_message( @@ -132,12 +103,9 @@ async def notify_admin(server_name: str): "Проверьте соединение к серверу, подключение к панели или удалите его из таблицы серверов в боте, " "чтобы не выдать подписку к неработающему серверу." ), - parse_mode="HTML", reply_markup=builder.as_markup(), ) - logger.info( - f"Уведомление отправлено администратору с ID {admin_id} о сервере {server_name}." - ) + logger.info(f"Уведомление отправлено администратору с ID {admin_id} о сервере {server_name}.") last_notification_times[server_name] = current_time except Exception as e: @@ -149,7 +117,7 @@ async def check_servers(): Периодическая проверка серверов с учетом извлечения хоста из `api_url`. """ while True: - servers = await get_servers_from_db() + servers = await get_servers() current_time = datetime.now() logger.info(f"Начинаю проверку серверов: {current_time}") @@ -161,9 +129,7 @@ async def check_servers(): server_name = server["server_name"] server_host = extract_host(original_api_url) - logger.debug( - f"Проверка доступности сервера '{server_name}' с хостом {server_host}" - ) + logger.debug(f"Проверка доступности сервера '{server_name}' с хостом {server_host}") is_online = await ping_server(server_host) @@ -171,18 +137,12 @@ async def check_servers(): last_ping_times[server_name] = current_time else: last_ping_time = last_ping_times.get(server_name) - if last_ping_time and current_time - last_ping_time > timedelta( - minutes=3 - ): - logger.warning( - f"Сервер {server_name} не отвечает более 3 минут. Отправляю уведомление." - ) + if last_ping_time and current_time - last_ping_time > timedelta(minutes=3): + logger.warning(f"Сервер {server_name} не отвечает более 3 минут. Отправляю уведомление.") await notify_admin(server_name) elif not last_ping_time: last_ping_times[server_name] = current_time - logger.info( - f"Сервер {server_name} не отвечал ранее, но теперь зарегистрирован." - ) + logger.info(f"Сервер {server_name} не отвечал ранее, но теперь зарегистрирован.") logger.info("Завершена проверка всех серверов.") await asyncio.sleep(PING_TIME) diff --git a/utils/csv_export.py b/utils/csv_export.py index 08cc1c59..4b39c023 100644 --- a/utils/csv_export.py +++ b/utils/csv_export.py @@ -35,10 +35,7 @@ async def export_users_csv(session: Any) -> BufferedInputFile: # Перемещение указателя в начало для чтения buffer.seek(0) - return BufferedInputFile( - file=buffer.getvalue().encode("utf-8-sig"), - filename="users_export.csv" - ) + return BufferedInputFile(file=buffer.getvalue().encode("utf-8-sig"), filename="users_export.csv") async def export_payments_csv(session: Any) -> BufferedInputFile: @@ -95,7 +92,4 @@ def _export_payments_csv(payments: list, filename: str) -> BufferedInputFile: # Перемещение указателя в начало для чтения buffer.seek(0) - return BufferedInputFile( - file=buffer.getvalue().encode("utf-8-sig"), - filename=filename - ) + return BufferedInputFile(file=buffer.getvalue().encode("utf-8-sig"), filename=filename)