diff --git a/api/routes/keys.py b/api/routes/keys.py index 790d3be4..da06675e 100644 --- a/api/routes/keys.py +++ b/api/routes/keys.py @@ -75,7 +75,6 @@ async def edit_key_by_email( session: AsyncSession = Depends(get_session), admin: Admin = Depends(verify_admin_token), ): - logger.info(f"[API] edit_key_by_email called for email={email}, payload={key_update.dict(exclude_unset=False)}") result = await session.execute(select(Key).where(Key.email == email)) db_key = result.scalar_one_or_none() @@ -96,7 +95,6 @@ async def edit_key_by_email( try: new_expiry_time = db_key.expiry_time - logger.info(f"[API] renew_key_in_cluster new_expiry_time (ms) = {new_expiry_time}") await renew_key_in_cluster( cluster_id=db_key.server_id, email=db_key.email, @@ -123,7 +121,6 @@ async def create_key_api( session: AsyncSession = Depends(get_session), admin: Admin = Depends(verify_admin_token), ): - logger.info(f"[API] Запрос на создание ключа: {payload.dict()}") try: await create_key_on_cluster( diff --git a/bot.py b/bot.py index dfc44c2c..d92f1080 100644 --- a/bot.py +++ b/bot.py @@ -1,6 +1,8 @@ import os import subprocess import traceback +import time +from functools import lru_cache from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties @@ -21,59 +23,61 @@ storage = MemoryStorage() dp = Dispatcher(bot=bot, storage=storage) -def get_git_commit_number() -> str: +_last_check_time = 0 +_last_git_info = "" + + +def _get_git_commit_number_uncached() -> str: repo_url = "https://github.com/Vladless/Solo_bot" cwd = os.path.abspath(os.path.dirname(__file__)) if not os.path.isdir(os.path.join(cwd, ".git")): cwd = "/root/Prod/Solo_bot" + logger.info(f"[Git] .git не найден в текущем каталоге, используем {cwd}") env = os.environ.copy() env["GIT_DIR"] = os.path.join(cwd, ".git") env["GIT_WORK_TREE"] = cwd try: - local_number = ( - subprocess.check_output(["git", "rev-list", "--count", "HEAD"], cwd=cwd, env=env).decode().strip() - ) - - local_hash = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=cwd, env=env).decode().strip() - + local_number = subprocess.check_output( + ["git", "rev-list", "--count", "HEAD"], cwd=cwd, env=env + ).decode().strip() + local_hash = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=cwd, env=env + ).decode().strip() try: - branch = ( - subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd, env=env).decode().strip() - ) - + branch = subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd, env=env + ).decode().strip() if branch == "HEAD": - describe = ( - subprocess.check_output( - ["git", "describe", "--tags", "--exact-match"], cwd=cwd, env=env, stderr=subprocess.DEVNULL - ) - .decode() - .strip() - ) - - if describe.startswith("v") or "release" in describe.lower(): - branch = "main" - else: - branch = "dev" + describe = subprocess.check_output( + ["git", "describe", "--tags", "--exact-match"], + cwd=cwd, + env=env, + stderr=subprocess.DEVNULL, + ).decode().strip() + branch = "main" if describe.startswith("v") or "release" in describe.lower() else "dev" except Exception: branch = "dev" except Exception as e: + logger.error(f"[Git] Ошибка при получении локального коммита: {e}") return f"\n(Требуется обновление через CLI (команда sudo solobot): {e})" try: + subprocess.check_output(["git", "fetch", "origin"], cwd=cwd, env=env) remote_commit = subprocess.check_output( ["git", "ls-remote", "origin", f"refs/heads/{branch}"], cwd=cwd, env=env ).decode() remote_hash = remote_commit.split()[0] - remote_number = ( - subprocess.check_output(["git", "rev-list", "--count", remote_hash], cwd=cwd, env=env).decode().strip() - ) + remote_number = subprocess.check_output( + ["git", "rev-list", "--count", remote_hash], cwd=cwd, env=env + ).decode().strip() if local_hash == remote_hash: + logger.info("[Git] Локальная версия актуальна") return "\n(Актуальная версия)" return ( @@ -81,12 +85,28 @@ def get_git_commit_number() -> str: f"#{local_number} / actual commit " f'#{remote_number})' ) - - except Exception: + except Exception as e: + logger.error(f"[Git] Ошибка при получении удалённого коммита: {e}") return "\n(Требуется обновление через CLI, команда sudo solobot)" -version = f"v4.4-preRelease{get_git_commit_number()}" +@lru_cache(maxsize=1) +def _cached_git_info() -> str: + return _get_git_commit_number_uncached() + + +def get_git_commit_number() -> str: + global _last_check_time, _last_git_info + now = time.time() + if now - _last_check_time > 3600: + _last_check_time = now + _cached_git_info.cache_clear() + _last_git_info = _cached_git_info() + return _last_git_info + + +def get_version() -> str: + return f"v4.4-preRelease{get_git_commit_number()}" dp.message.filter(IsPrivateFilter()) diff --git a/cli_launcher.py b/cli_launcher.py index 6d5bd7c6..7ac8cf4a 100755 --- a/cli_launcher.py +++ b/cli_launcher.py @@ -2,6 +2,7 @@ import os import re import subprocess import sys +import shutil import requests @@ -69,18 +70,51 @@ def backup_project(): console.print(f"[green]✅ Бэкап сохранён в: {BACK_DIR}[/green]") +def auto_update_cli(): + """Обновляет CLI, если отличается от последней версии. Перезапускает при необходимости.""" + console.print("[yellow]🔄 Проверка обновлений CLI...[/yellow]") + try: + url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py" + response = requests.get(url, timeout=10) + if response.status_code != 200: + console.print("[red]⚠️ Не удалось получить обновление CLI[/red]") + return + + latest_text = response.text + current_path = os.path.realpath(__file__) + with open(current_path, encoding="utf-8") as f: + current_text = f.read() + + if current_text != latest_text: + console.print("[green]🆕 Доступна новая версия CLI. Обновляю...[/green]") + with open(current_path, "w", encoding="utf-8") as f: + f.write(latest_text) + os.chmod(current_path, 0o755) + console.print("[green]✅ CLI обновлён. Перезапуск...[/green]") + os.execv(sys.executable, [sys.executable, current_path]) + else: + console.print("[green]✅ CLI уже актуален[/green]") + except Exception as e: + console.print(f"[red]❌ Ошибка при автообновлении CLI: {e}[/red]") + + def fix_permissions(): """Устанавливает корректные права на файлы проекта""" console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]") - try: - user = os.getenv("SUDO_USER") or os.getenv("USER") - if user: - subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True) + if os.geteuid() != 0: + console.print("[yellow]⚠️ Некоторые действия могут требовать прав администратора (sudo)[/yellow]") + + try: + stat_info = os.stat(PROJECT_DIR) + uid = stat_info.st_uid + user = subprocess.check_output(["id", "-nu", str(uid)], text=True).strip() + + subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True) subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True) - console.print("[green]✅ Права успешно установлены[/green]") - except subprocess.CalledProcessError as e: + console.print(f"[green]✅ Права установлены для пользователя {user}[/green]") + except Exception as e: console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]") @@ -138,19 +172,26 @@ def install_git_if_needed(): def install_dependencies(): console.print("[blue]🔧 Установка зависимостей...[/blue]") + + python312_path = shutil.which("python3.12") + if not python312_path: + console.print("[red]❌ Не найден python3.12 в системе[/red]") + console.print("[yellow]📦 Установите Python 3.12: sudo apt install python3.12 python3.12-venv[/yellow]") + sys.exit(1) + with console.status("[bold green]Устанавливаются зависимости...[/bold green]"): try: if not os.path.exists("venv"): - console.print("[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]") - subprocess.run("python3 -m venv venv", shell=True, check=True) + console.print("[yellow]⚠️ Виртуальное окружение не найдено. Создаю через python3.12...[/yellow]") + subprocess.run(f"{python312_path} -m venv venv", shell=True, check=True) subprocess.run( "bash -c 'source venv/bin/activate && pip install -r requirements.txt'", shell=True, check=True, ) - except subprocess.CalledProcessError: - console.print("[red]❌ Ошибка при установке зависимостей.[/red]") + except subprocess.CalledProcessError as e: + console.print(f"[red]❌ Ошибка при установке зависимостей: {e}[/red]") def restart_service(): @@ -189,7 +230,6 @@ def get_remote_version(branch="main"): def update_from_beta(): - update_cli_launcher() local_version = get_local_version() remote_version = get_remote_version(branch="dev") @@ -246,7 +286,6 @@ def update_from_beta(): def update_from_release(): - update_cli_launcher() if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"): return @@ -340,7 +379,7 @@ def show_update_menu(): def show_menu(): - table = Table(title="Solobot CLI v0.2.0", title_style="bold magenta", header_style="bold blue") + table = Table(title="Solobot CLI v0.2.7", title_style="bold magenta", header_style="bold blue") table.add_column("№", justify="center", style="cyan", no_wrap=True) table.add_column("Операция", style="white") table.add_row("1", "Запустить бота (systemd)") @@ -350,31 +389,13 @@ def show_menu(): table.add_row("5", "Показать логи (80 строк)") table.add_row("6", "Показать статус") table.add_row("7", "Обновить Solobot") - table.add_row("8", "Обновить CLI лаунчер") - table.add_row("9", "Выход") + table.add_row("8", "Выход") console.print(table) -def update_cli_launcher(): - """Обновляет CLI лаунчер с dev ветки""" - console.print("[yellow]🔄 Обновление CLI лаунчера...[/yellow]") - try: - url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py" - response = requests.get(url, timeout=10) - - if response.status_code == 200: - with open(os.path.join(PROJECT_DIR, "cli_launcher.py"), "w", encoding="utf-8") as f: - f.write(response.text) - console.print("[green]✅ CLI лаунчер успешно обновлён[/green]") - os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755) - else: - console.print("[red]❌ Не удалось загрузить новый CLI[/red]") - except Exception as e: - console.print(f"[red]❌ Ошибка при обновлении CLI: {e}[/red]") - - def main(): os.chdir(PROJECT_DIR) + auto_update_cli() print_logo() try: while True: @@ -424,11 +445,6 @@ def main(): elif choice == "7": show_update_menu() elif choice == "8": - if Confirm.ask("[yellow]Обновить CLI лаунчер с dev ветки?[/yellow]"): - update_cli_launcher() - elif choice == "9": - if Confirm.ask("[yellow]Хотите обновить CLI перед выходом?[/yellow]"): - update_cli_launcher() console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]") break except KeyboardInterrupt: diff --git a/database/importer.py b/database/importer.py index 717ae347..3dc8d5a0 100644 --- a/database/importer.py +++ b/database/importer.py @@ -8,6 +8,7 @@ from itertools import cycle from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession +from config import USE_COUNTRY_SELECTION from database.models import Key, Server, User @@ -16,15 +17,23 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple imported = 0 skipped = 0 - result = await session.execute( - select(Server.cluster_name) - .where(Server.enabled is True, Server.panel_type == "3x-ui", Server.cluster_name.isnot(None)) - .distinct() - ) - clusters = [row[0] for row in result.fetchall()] - if not clusters: - raise RuntimeError("❌ Не найдено доступных кластеров для 3x-ui") - cluster_cycle = cycle(clusters) + if USE_COUNTRY_SELECTION: + result = await session.execute( + select(Server.name) + .where(Server.enabled == True, Server.panel_type == "3x-ui") + ) + else: + result = await session.execute( + select(Server.cluster_name) + .where(Server.enabled == True, Server.panel_type == "3x-ui", Server.cluster_name.isnot(None)) + .distinct() + ) + + server_ids = [row[0] for row in result.fetchall()] + if not server_ids: + raise RuntimeError("❌ Не найдено доступных серверов или кластеров для 3x-ui") + + server_cycle = cycle(server_ids) try: conn = sqlite3.connect(db_path) @@ -58,7 +67,7 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple email = c.get("email") expiry_time = int(c.get("expiryTime") or now_ts) created_at = now_ts - server_id = next(cluster_cycle) + server_id = next(server_cycle) if not tg_id or not client_id: continue @@ -112,4 +121,4 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple continue await session.commit() - return imported, skipped + return imported, skipped \ No newline at end of file diff --git a/database/tariffs.py b/database/tariffs.py index c7750868..871ba41c 100644 --- a/database/tariffs.py +++ b/database/tariffs.py @@ -130,7 +130,6 @@ async def check_tariff_exists(session: AsyncSession, tariff_id: int): result = await session.execute(select(Tariff).where(Tariff.id == tariff_id, Tariff.is_active.is_(True))) tariff = result.scalar_one_or_none() if tariff: - logger.info(f"[TARIFF] Тариф {tariff_id} найден в БД: {tariff.group_code}") return True logger.warning(f"[TARIFF] Тариф {tariff_id} не найден в БД") return False diff --git a/handlers/admin/management/keyboard.py b/handlers/admin/management/keyboard.py index 491790cb..4954d9ca 100644 --- a/handlers/admin/management/keyboard.py +++ b/handlers/admin/management/keyboard.py @@ -28,13 +28,17 @@ def build_management_kb(admin_role: str) -> InlineKeyboardMarkup: callback_data=AdminPanelCallback(action="restart").pack(), ) builder.button( - text="🌐 Сменить домен", + text="🌐 Сменить домен подписок", callback_data=AdminPanelCallback(action="change_domain").pack(), ) builder.button( text="🔑 Восстановить пробники", callback_data=AdminPanelCallback(action="restore_trials").pack(), ) + builder.button( + text="📤 Загрузить файл", + callback_data=AdminPanelCallback(action="upload_file").pack(), + ) maintenance_text = "🛠️ Выключить тех. работы" if maintenance.maintenance_mode else "🛠️ Включить тех. работы" builder.button( text=maintenance_text, @@ -54,11 +58,11 @@ def build_database_kb() -> InlineKeyboardMarkup: callback_data=AdminPanelCallback(action="backups").pack(), ) builder.button( - text="♻️ Восстановить БД", + text="♻️ Восстановить БД из бэкапа", callback_data=AdminPanelCallback(action="restore_db").pack(), ) builder.button( - text="📤 Получить данные БД", + text="📤 Получить данные БД из панели", callback_data=AdminPanelCallback(action="export_db").pack(), ) builder.row(build_admin_back_btn()) diff --git a/handlers/admin/management/management_handler.py b/handlers/admin/management/management_handler.py index 59492866..aa311d3d 100644 --- a/handlers/admin/management/management_handler.py +++ b/handlers/admin/management/management_handler.py @@ -54,6 +54,10 @@ class Import3xuiStates(StatesGroup): waiting_for_file = State() +class FileUploadState(StatesGroup): + waiting_for_file = State() + + class DatabaseState(StatesGroup): waiting_for_backup_file = State() @@ -92,7 +96,6 @@ async def request_new_domain(callback_query: CallbackQuery, state: FSMContext): async def process_new_domain(message: Message, state: FSMContext, session: AsyncSession): """Обновляет домен в таблице keys.""" new_domain = message.text.strip() - logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'") if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum(): logger.warning("[DomainChange] Некорректный домен") @@ -103,7 +106,6 @@ async def process_new_domain(message: Message, state: FSMContext, session: Async return new_domain_url = f"https://{new_domain}" - logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'") try: stmt = ( @@ -326,8 +328,6 @@ async def restore_database(message: Message, state: FSMContext, bot: Bot): if signature == b"PGDMP": is_custom_dump = True - logger.info(f"[Restore] Определён формат: {'custom' if is_custom_dump else 'plain'}") - subprocess.run( [ "sudo", @@ -645,6 +645,46 @@ async def handle_resync_after_import(callback: CallbackQuery, session: AsyncSess await callback.message.edit_text( f"🔁 Перевыпуск завершён:\n✅ Успешно: {success}\n❌ Ошибки: {failed}", - parse_mode="HTML", reply_markup=build_back_to_db_menu(), ) + + +@router.callback_query(AdminPanelCallback.filter(F.action == "upload_file")) +async def prompt_for_file_upload(callback: CallbackQuery, state: FSMContext): + await callback.message.edit_text( + "📤 Загрузка файла\n\n" + "Вы можете заменить файл в корневой директории бота.\n\n" + "📁 Отправьте файл с таким же именем и расширением, " + "как у уже существующего файла. Он будет автоматически заменён.", + reply_markup=build_admin_back_kb("management"), + ) + await state.set_state(FileUploadState.waiting_for_file) + + + +@router.message(FileUploadState.waiting_for_file, F.document) +async def handle_admin_file_upload(message: Message, state: FSMContext): + document = message.document + file_name = document.file_name + + if not file_name or "." not in file_name: + await message.answer("❌ У файла должно быть имя с расширением.") + return + + dest_path = os.path.abspath(f"./{file_name}") + + try: + await message.bot.download(document, destination=dest_path) + await message.answer( + f"✅ Файл {file_name} успешно загружен и заменён.\n\n" + "🔄 Перезагрузите бота, чтобы изменения вступили в силу.", + reply_markup=build_admin_back_kb("management"), + ) + except Exception as e: + logger.error(f"[Upload File] Ошибка при загрузке файла {file_name}: {e}") + await message.answer( + f"❌ Не удалось сохранить файл: {e}", + reply_markup=build_admin_back_kb("management"), + ) + await state.clear() + diff --git a/handlers/admin/panel/panel_handler.py b/handlers/admin/panel/panel_handler.py index a56ac18b..ed1759e3 100644 --- a/handlers/admin/panel/panel_handler.py +++ b/handlers/admin/panel/panel_handler.py @@ -6,7 +6,7 @@ from aiogram.types import CallbackQuery, Message from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from bot import version +from bot import get_version from database.models import Admin from filters.admin import IsAdminFilter from logger import logger @@ -19,7 +19,7 @@ router = Router() @router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter()) async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession): - text = f"🤖 Панель администратора\n\nВерсия бота:\n
{version}
" + text = f"🤖 Панель администратора\n\nВерсия бота:\n
{get_version()}
" await state.clear() @@ -60,7 +60,7 @@ async def handle_admin_callback_query_simple(callback_query: CallbackQuery, stat @router.message(Command("admin"), IsAdminFilter()) async def handle_admin_message(message: Message, state: FSMContext, session: AsyncSession): - text = f"🤖 Панель администратора\n\nВерсия бота:\n
{version}
" + text = f"🤖 Панель администратора\n\nВерсия бота:\n
{get_version()}
" await state.clear() diff --git a/handlers/keys/key_freeze.py b/handlers/keys/key_freeze.py index be275389..8b03fe32 100644 --- a/handlers/keys/key_freeze.py +++ b/handlers/keys/key_freeze.py @@ -98,7 +98,6 @@ async def process_callback_unfreeze_subscription_confirm(callback_query: Callbac now_ms = int(time.time() * 1000) leftover = record["expiry_time"] - logger.info(f"[Unfreeze Debug] expiry_time из БД: {leftover}") if leftover < 0: leftover = 0 new_expiry_time = now_ms + leftover diff --git a/handlers/profile.py b/handlers/profile.py index 46f13808..b33333b6 100644 --- a/handlers/profile.py +++ b/handlers/profile.py @@ -70,7 +70,6 @@ async def process_callback_view_profile( username = "Пользователь" image_path = os.path.join("img", "profile.jpg") - logger.info(f"Переход в профиль. Используется изображение: {image_path}") key_count = await get_key_count(session, chat_id) balance = await get_balance(session, chat_id) or 0 diff --git a/handlers/start.py b/handlers/start.py index 421221bf..b55d2044 100644 --- a/handlers/start.py +++ b/handlers/start.py @@ -300,7 +300,6 @@ async def handle_utm_link( async def show_start_menu(message: Message, admin: bool, session: AsyncSession): """Функция для отображения стандартного меню через редактирование сообщения.""" - logger.info(f"Показываю главное меню для пользователя {message.chat.id}") image_path = os.path.join("img", "pic.jpg") builder = InlineKeyboardBuilder() @@ -308,7 +307,6 @@ async def show_start_menu(message: Message, admin: bool, session: AsyncSession): trial_status = None if session is not None: trial_status = await get_trial(session, message.chat.id) - logger.info(f"Trial status для {message.chat.id}: {trial_status}") else: logger.warning(f"Сессия базы данных отсутствует, пропускаем проверку триала для {message.chat.id}")