From 19ae3d161110fd2010bdb2315df4c4b58fbae7fd Mon Sep 17 00:00:00 2001 From: Vladless Date: Thu, 19 Mar 2026 23:27:50 +0300 Subject: [PATCH] CLI update --- cli_launcher.py | 527 ++++++++++++++++++++++++-- core/tasks/periodic_manager.py | 5 +- handlers/admin/stats/keyboard.py | 9 +- handlers/admin/stats/stats_handler.py | 68 ---- logger.py | 12 +- 5 files changed, 520 insertions(+), 101 deletions(-) diff --git a/cli_launcher.py b/cli_launcher.py index 94d87ba6..4640dee0 100755 --- a/cli_launcher.py +++ b/cli_launcher.py @@ -1,22 +1,154 @@ import locale +import json import os import re import shutil import subprocess import sys +from contextlib import contextmanager +from datetime import datetime from time import sleep +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen -import requests +try: + import requests +except ImportError: + requests = None -from rich.console import Console, Group -from rich.live import Live -from rich.panel import Panel -from rich.progress import Progress, SpinnerColumn, TextColumn -from rich.prompt import Confirm, Prompt -from rich.table import Table +try: + from rich.console import Console, Group + from rich.live import Live + from rich.panel import Panel + from rich.progress import Progress, SpinnerColumn, TextColumn + from rich.prompt import Confirm, Prompt + from rich.table import Table +except ImportError: + def _strip_markup(value): + if not isinstance(value, str): + return str(value) + return re.sub(r"\[[^\]]+\]", "", value) -from config import BOT_SERVICE + + class Group: + def __init__(self, *items): + self.items = items + + def __str__(self): + return "\n".join(_strip_markup(item) for item in self.items) + + + class Panel: + def __init__(self, renderable, **kwargs): + self.renderable = renderable + + def __str__(self): + return _strip_markup(self.renderable) + + + class Table: + def __init__(self, title=None, **kwargs): + self.title = title + self.rows = [] + + def add_column(self, *args, **kwargs): + return None + + def add_row(self, *row): + self.rows.append(row) + + def __str__(self): + lines = [] + if self.title: + lines.append(_strip_markup(self.title)) + lines.extend(" | ".join(_strip_markup(cell) for cell in row) for row in self.rows) + return "\n".join(lines) + + + class Live: + def __init__(self, **kwargs): + self.last_renderable = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def update(self, renderable): + self.last_renderable = renderable + print(_strip_markup(str(renderable))) + + + class SpinnerColumn: + pass + + + class TextColumn: + def __init__(self, *args, **kwargs): + pass + + + class Progress: + def __init__(self, *args, **kwargs): + self.last_description = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def add_task(self, description, total=None): + self.last_description = description + print(_strip_markup(description)) + return 1 + + def update(self, task_id, description=None): + if description and description != self.last_description: + self.last_description = description + print(_strip_markup(description)) + + + class Prompt: + @staticmethod + def ask(message, choices=None, default=None, show_choices=True, **kwargs): + suffix = "" + if choices and show_choices: + suffix = f" ({'/'.join(choices)})" + if default is not None: + suffix = f"{suffix} [{default}]" + value = input(f"{_strip_markup(message)}{suffix}: ").strip() + if not value and default is not None: + value = str(default) + if choices and value not in choices: + raise ValueError(f"Ожидается одно из значений: {', '.join(choices)}") + return value + + + class Confirm: + @staticmethod + def ask(message, default=False, **kwargs): + prompt = "Y/n" if default else "y/N" + value = input(f"{_strip_markup(message)} [{prompt}]: ").strip().lower() + if not value: + return default + return value in {"y", "yes", "1", "true"} + + + class Console: + def print(self, *args, **kwargs): + print(*(_strip_markup(str(arg)) for arg in args)) + + def log(self, *args, **kwargs): + self.print(*args) + + @contextmanager + def status(self, message): + self.print(message) + yield def ensure_utf8_locale(): @@ -59,7 +191,59 @@ TEMP_DIR = os.path.expanduser("~/.solobot_tmp") PROJECT_DIR = os.path.abspath(os.path.dirname(__file__)) IS_ROOT_DIR = PROJECT_DIR == "/root" GITHUB_REPO = "https://github.com/Vladless/Solo_bot" -SERVICE_NAME = BOT_SERVICE +DEFAULT_SERVICE_NAME = "bot.service" +VENV_PYTHON = os.path.join(PROJECT_DIR, "venv", "bin", "python") + + +class HttpResponse: + def __init__(self, status_code: int, text: str): + self.status_code = status_code + self.text = text + + def json(self): + return json.loads(self.text) + + +def http_get(url: str, *, params=None, timeout: int = 10) -> HttpResponse: + if requests is not None: + response = requests.get(url, params=params, timeout=timeout) + return HttpResponse(response.status_code, response.text) + + final_url = url + if params: + final_url = f"{url}?{urlencode(params)}" + request = Request(final_url, headers={"User-Agent": "SoloBot-CLI"}) + try: + with urlopen(request, timeout=timeout) as response: + return HttpResponse(response.status, response.read().decode("utf-8")) + except HTTPError as error: + return HttpResponse(error.code, error.read().decode("utf-8", errors="replace")) + except URLError: + return HttpResponse(599, "") + + +def detect_service_name() -> str: + config_path = os.path.join(PROJECT_DIR, "config.py") + if os.path.isfile(config_path): + try: + with open(config_path, encoding="utf-8") as config_file: + config_text = config_file.read() + match = re.search(r"BOT_SERVICE\s*=\s*['\"]([^'\"]+)['\"]", config_text) + if match: + return match.group(1) + except Exception: + pass + return DEFAULT_SERVICE_NAME + + +def refresh_service_name() -> str: + global SERVICE_NAME, SYSTEMD_SERVICE_PATH + SERVICE_NAME = detect_service_name() + SYSTEMD_SERVICE_PATH = os.path.join("/etc/systemd/system", SERVICE_NAME) + return SERVICE_NAME + + +SERVICE_NAME = refresh_service_name() def is_ascii_only(value: str) -> bool: @@ -103,6 +287,9 @@ def safe_prompt(message: str, **kwargs) -> str: except UnicodeDecodeError: warn_english_only() continue + except ValueError as e: + console.print(f"[red]{e}[/red]") + continue if isinstance(value, str) and not is_ascii_only(value): warn_english_only() continue @@ -123,6 +310,236 @@ def is_service_exists(service_name): return service_name in result.stdout +def get_runtime_user() -> str: + return os.environ.get("SUDO_USER") or subprocess.check_output(["whoami"], text=True).strip() + + +def has_project_code() -> bool: + required_paths = ("requirements.txt", "main.py") + return all(os.path.exists(os.path.join(PROJECT_DIR, path)) for path in required_paths) + + +def has_local_config() -> bool: + return os.path.exists(os.path.join(PROJECT_DIR, "config.py")) + + +def bootstrap_project_files(branch: str = "main") -> bool: + refresh_service_name() + if has_project_code(): + return True + + console.print("[yellow]Полный проект рядом не найден. Подтягиваю файлы бота...[/yellow]") + install_core_packages_if_needed() + install_rsync_if_needed() + + subprocess.run(["rm", "-rf", TEMP_DIR], check=False) + clone_result = subprocess.run( + ["git", "clone", "--depth", "1", "--branch", branch, GITHUB_REPO, TEMP_DIR], + check=False, + ) + if clone_result.returncode != 0: + console.print("[red]❌ Не удалось скачать проект из GitHub.[/red]") + return False + + rsync_cmd = ["rsync", "-a", f"{TEMP_DIR}/", f"{PROJECT_DIR}/"] + if has_local_config(): + rsync_cmd.insert(2, "--exclude=config.py") + if os.path.exists(os.path.join(PROJECT_DIR, "handlers", "texts.py")): + rsync_cmd.insert(2, "--exclude=handlers/texts.py") + if os.path.exists(os.path.join(PROJECT_DIR, "handlers", "buttons.py")): + rsync_cmd.insert(2, "--exclude=handlers/buttons.py") + if os.path.exists(os.path.join(PROJECT_DIR, "core", "redis_cache.py")): + rsync_cmd.insert(2, "--exclude=core/redis_cache.py") + if os.path.exists(os.path.join(PROJECT_DIR, "img")): + rsync_cmd.insert(2, "--exclude=img") + if os.path.exists(os.path.join(PROJECT_DIR, "modules")): + rsync_cmd.insert(2, "--exclude=modules") + rsync_cmd.insert(2, "--exclude=.git") + + sync_result = subprocess.run(rsync_cmd, check=False) + subprocess.run(["rm", "-rf", TEMP_DIR], check=False) + if sync_result.returncode != 0: + console.print("[red]❌ Не удалось распаковать файлы проекта.[/red]") + return False + + refresh_service_name() + console.print("[green]Файлы проекта подготовлены.[/green]") + return True + + +def install_core_packages_if_needed(): + missing_packages = [] + + if shutil.which("git") is None: + missing_packages.append("git") + if shutil.which("rsync") is None: + missing_packages.append("rsync") + + python312_path = shutil.which("python3.12") + if python312_path is None: + missing_packages.extend(["python3.12", "python3.12-venv"]) + else: + venv_check = subprocess.run( + [python312_path, "-m", "venv", "--help"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if venv_check.returncode != 0: + missing_packages.append("python3.12-venv") + + if not missing_packages: + return + + unique_packages = list(dict.fromkeys(missing_packages)) + console.print(f"[yellow]Устанавливаю системные пакеты: {', '.join(unique_packages)}[/yellow]") + subprocess.run(["sudo", "apt", "update"], check=True) + subprocess.run(["sudo", "apt", "install", "-y", *unique_packages], check=True) + + +def build_systemd_service() -> str: + run_user = get_runtime_user() + return ( + "[Unit]\n" + "Description=SoloBot Telegram bot\n" + "After=network.target\n\n" + "[Service]\n" + f"User={run_user}\n" + f"WorkingDirectory={PROJECT_DIR}\n" + f"ExecStart={VENV_PYTHON} {os.path.join(PROJECT_DIR, 'main.py')}\n" + "Restart=always\n" + "RestartSec=5\n" + 'Environment="PYTHONUNBUFFERED=1"\n\n' + "[Install]\n" + "WantedBy=multi-user.target\n" + ) + + +def ensure_systemd_service() -> bool: + refresh_service_name() + console.print(f"[yellow]Проверяю systemd-службу {SERVICE_NAME}...[/yellow]") + service_text = build_systemd_service() + service_exists = os.path.exists(SYSTEMD_SERVICE_PATH) + + if service_exists: + try: + with open(SYSTEMD_SERVICE_PATH, encoding="utf-8") as service_file: + if service_file.read() == service_text: + console.print(f"[green]Служба {SERVICE_NAME} уже настроена.[/green]") + return True + except Exception: + pass + + try: + subprocess.run( + ["sudo", "tee", SYSTEMD_SERVICE_PATH], + input=service_text, + text=True, + stdout=subprocess.DEVNULL, + check=True, + ) + subprocess.run(["sudo", "systemctl", "daemon-reload"], check=True) + console.print(f"[green]Служба {SERVICE_NAME} настроена.[/green]") + return True + except Exception as e: + console.print(f"[red]❌ Не удалось настроить службу {SERVICE_NAME}: {e}[/red]") + return False + + +def initialize_database() -> bool: + if not os.path.exists(VENV_PYTHON): + console.print("[yellow]Инициализация базы пропущена: виртуальное окружение ещё не создано.[/yellow]") + return False + console.print("[yellow]Инициализация базы данных...[/yellow]") + try: + subprocess.run( + [ + VENV_PYTHON, + "-c", + "import asyncio; from database.init_db import init_db; asyncio.run(init_db())", + ], + cwd=PROJECT_DIR, + check=True, + ) + console.print("[green]База данных успешно инициализирована.[/green]") + return True + except Exception as e: + console.print(f"[red]❌ Не удалось инициализировать базу данных: {e}[/red]") + return False + + +def enable_and_start_service(start_now: bool = True) -> None: + refresh_service_name() + subprocess.run(["sudo", "systemctl", "daemon-reload"], check=True) + subprocess.run(["sudo", "systemctl", "enable", SERVICE_NAME], check=True) + if start_now: + subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME], check=True) + console.print(f"[green]Служба {SERVICE_NAME} включена и запущена.[/green]") + else: + console.print( + f"[yellow]Служба {SERVICE_NAME} включена, но не запущена. Проверьте config.py и доступность базы данных.[/yellow]" + ) + + +def is_runtime_ready() -> bool: + refresh_service_name() + if not has_project_code(): + return False + return os.path.exists(VENV_PYTHON) and is_service_exists(SERVICE_NAME) + + +def install_bot(): + console.print( + Panel( + "[white]CLI подготовит окружение, установит зависимости, создаст systemd-службу " + "и попробует инициализировать базу данных. Если проекта ещё нет рядом, CLI сначала скачает его автоматически.[/white]", + border_style="green", + title="[bold green]Автоматическая установка SoloBot[/bold green]", + padding=(1, 2), + ) + ) + + if not safe_confirm("[bold green]Запустить автоматическую установку?[/bold green]", default=True): + return + + try: + branch = "main" + if not has_project_code(): + use_beta = safe_confirm("[yellow]Скачать beta/dev ветку вместо стабильной?[/yellow]", default=False) + branch = "dev" if use_beta else "main" + if not bootstrap_project_files(branch=branch): + return + refresh_service_name() + install_core_packages_if_needed() + install_dependencies() + db_ready = initialize_database() + if not ensure_systemd_service(): + return + fix_permissions() + enable_and_start_service(start_now=db_ready) + console.print("[green]✅ Установка SoloBot завершена.[/green]") + except subprocess.CalledProcessError as e: + console.print(f"[red]❌ Ошибка во время установки: {e}[/red]") + + +def prompt_install_if_needed(): + if is_runtime_ready(): + return + + missing_parts = [] + if not has_project_code(): + missing_parts.append("файлы проекта") + if has_project_code() and not os.path.exists(VENV_PYTHON): + missing_parts.append("виртуальное окружение") + refresh_service_name() + if has_project_code() and not is_service_exists(SERVICE_NAME): + missing_parts.append(f"служба {SERVICE_NAME}") + + console.print(f"[yellow]Обнаружена неполная установка: {', '.join(missing_parts)}.[/yellow]") + if safe_confirm("[green]Выполнить автоматическую установку сейчас?[/green]", default=True): + install_bot() + + def print_logo(): logo_lines = [ "███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗", @@ -141,7 +558,11 @@ def print_logo(): live.update(panel) sleep(0.07) - console.print(f"[bold green]Директория бота:[/bold green] [yellow]{PROJECT_DIR}[/yellow]\n") + local_version = get_local_version() or "unknown" + last_update = get_last_update_date() or "unknown" + console.print(f"[bold green]Директория бота:[/bold green] [yellow]{PROJECT_DIR}[/yellow]") + console.print(f"[bold green]Установленная версия:[/bold green] [yellow]{local_version}[/yellow]") + console.print(f"[bold green]Последнее обновление:[/bold green] [yellow]{last_update}[/yellow]\n") def list_backups(): @@ -240,7 +661,7 @@ def auto_update_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) + response = http_get(url, timeout=10) if response.status_code != 200: console.print("[red]Не удалось получить обновление CLI[/red]") return @@ -298,9 +719,7 @@ def fix_permissions(): def install_rsync_if_needed(): - if subprocess.run(["which", "rsync"], capture_output=True).returncode != 0: - console.print("[blue]Установка rsync...[/blue]") - os.system("sudo apt update && sudo apt install -y rsync") + install_core_packages_if_needed() def clean_project_dir_safe(update_buttons=False, update_img=False, update_redis_cache=False): @@ -363,13 +782,12 @@ def clean_project_dir_safe(update_buttons=False, update_img=False, update_redis_ def install_git_if_needed(): - if subprocess.run(["which", "git"], capture_output=True).returncode != 0: - console.print("[blue]Установка Git...[/blue]") - os.system("sudo apt update && sudo apt install -y git") + install_core_packages_if_needed() def install_dependencies(): console.print("[blue]Установка зависимостей...[/blue]") + install_core_packages_if_needed() python312_path = shutil.which("python3.12") if not python312_path: @@ -405,15 +823,27 @@ def install_dependencies(): def restart_service(): - if is_service_exists(SERVICE_NAME): + if ensure_systemd_service(): console.print("[blue]🚀 Перезапуск службы...[/blue]") with console.status("[bold yellow]Перезапуск...[/bold yellow]"): + subprocess.run(["sudo", "systemctl", "enable", SERVICE_NAME], check=False) subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME]) - else: - console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]") def get_local_version(): + try: + result = subprocess.run( + ["git", "-C", PROJECT_DIR, "describe", "--tags", "--always"], + capture_output=True, + text=True, + check=False, + ) + version = result.stdout.strip() + if result.returncode == 0 and version: + return version + except Exception: + pass + path = os.path.join(PROJECT_DIR, "bot.py") if not os.path.isfile(path): return None @@ -425,10 +855,39 @@ def get_local_version(): return None +def get_last_update_date(): + try: + result = subprocess.run( + ["git", "-C", PROJECT_DIR, "log", "-1", "--format=%cd", "--date=format:%Y-%m-%d %H:%M:%S"], + capture_output=True, + text=True, + check=False, + ) + value = result.stdout.strip() + if result.returncode == 0 and value: + return value + except Exception: + pass + + excluded_dirs = {".git", "venv", ".venv", "__pycache__", "build", "dist"} + latest_mtime = 0.0 + for root, dirs, files in os.walk(PROJECT_DIR): + dirs[:] = [d for d in dirs if d not in excluded_dirs] + for file_name in files: + path = os.path.join(root, file_name) + try: + latest_mtime = max(latest_mtime, os.path.getmtime(path)) + except Exception: + continue + if latest_mtime <= 0: + return None + return datetime.fromtimestamp(latest_mtime).strftime("%Y-%m-%d %H:%M:%S") + + def get_remote_version(branch="main"): try: url = f"https://raw.githubusercontent.com/Vladless/Solo_bot/{branch}/bot.py" - response = requests.get(url, timeout=10) + response = http_get(url, timeout=10) if response.status_code == 200: for line in response.text.splitlines(): match = re.search(r'version\s*=\s*["\'](.+?)["\']', line) @@ -596,14 +1055,14 @@ def update_from_release(): install_rsync_if_needed() try: - rel_resp = requests.get( + rel_resp = http_get( "https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10, ) releases = rel_resp.json() if rel_resp.status_code == 200 else [] release_tag_names = {r["tag_name"] for r in releases} - tags_resp = requests.get( + tags_resp = http_get( "https://api.github.com/repos/Vladless/Solo_bot/tags", params={"per_page": 50}, timeout=10, @@ -664,7 +1123,7 @@ def show_update_menu(): def show_menu(): - table = Table(title="Solobot CLI v0.4.0", title_style="bold magenta", header_style="bold blue") + table = Table(title="Solobot CLI v0.5.0", 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)") @@ -675,28 +1134,38 @@ def show_menu(): table.add_row("6", "Показать статус") table.add_row("7", "Обновить Solobot") table.add_row("8", "Восстановить из бэкапа") - table.add_row("9", "Выход") + table.add_row("9", "Установить / переустановить бота") + table.add_row("10", "Выход") console.print(table) def main(): os.chdir(PROJECT_DIR) - auto_update_cli() + # auto_update_cli() print_logo() + prompt_install_if_needed() try: while True: + refresh_service_name() show_menu() choice = safe_prompt( "[bold blue]👉 Введите номер действия[/bold blue]", - choices=[str(i) for i in range(1, 10)], + choices=[str(i) for i in range(1, 11)], show_choices=False, ) if choice == "1": if is_service_exists(SERVICE_NAME): subprocess.run(["sudo", "systemctl", "start", SERVICE_NAME]) else: - console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]") + console.print(f"[yellow]Служба {SERVICE_NAME} не найдена.[/yellow]") + if safe_confirm("[green]Установить бота и создать службу сейчас?[/green]", default=True): + install_bot() elif choice == "2": + if not os.path.exists(VENV_PYTHON): + console.print("[yellow]Виртуальное окружение ещё не создано.[/yellow]") + if safe_confirm("[green]Подготовить окружение через автоматическую установку?[/green]", default=True): + install_bot() + continue if safe_confirm("[green]Вы действительно хотите запустить main.py вручную?[/green]"): subprocess.run(["venv/bin/python", "main.py"]) elif choice == "3": @@ -734,6 +1203,8 @@ def main(): elif choice == "8": restore_from_backup() elif choice == "9": + install_bot() + elif choice == "10": console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]") break except KeyboardInterrupt: diff --git a/core/tasks/periodic_manager.py b/core/tasks/periodic_manager.py index 6e444d0c..6e2d19ab 100644 --- a/core/tasks/periodic_manager.py +++ b/core/tasks/periodic_manager.py @@ -10,6 +10,8 @@ from dataclasses import dataclass from typing import Literal from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode from apscheduler.executors.asyncio import AsyncIOExecutor from apscheduler.executors.pool import ProcessPoolExecutor as APSchedulerProcessPoolExecutor from apscheduler.executors.pool import ThreadPoolExecutor as APSchedulerThreadPoolExecutor @@ -69,10 +71,11 @@ def _run_process_loop_task(task_id: str, runner: LoopRunner) -> None: async def _run_process_loop_task_async(task_id: str, runner: LoopRunner) -> None: from database.db import reset_async_db_engine - from bot import bot from core.bootstrap import bootstrap + from config import API_TOKEN from database import async_session_maker, init_db + bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) logger.info("[PeriodicManager] Process-loop задача {} запущена, PID={}", task_id, os.getpid()) try: reset_async_db_engine() diff --git a/handlers/admin/stats/keyboard.py b/handlers/admin/stats/keyboard.py index 90e402fb..7e899207 100644 --- a/handlers/admin/stats/keyboard.py +++ b/handlers/admin/stats/keyboard.py @@ -13,7 +13,8 @@ def build_audit_refresh_kb(source: str = "db") -> InlineKeyboardMarkup: builder.button(text=redis_text, callback_data=AdminPanelCallback(action="audit_refresh_redis").pack()) builder.button(text=db_text, callback_data=AdminPanelCallback(action="audit_refresh_db").pack()) builder.button(text=reset_text, callback_data=AdminPanelCallback(action=f"audit_reset_ask_{source}").pack()) - builder.adjust(2, 1) + builder.button(text="Администратор", callback_data=AdminPanelCallback(action="admin").pack()) + builder.adjust(2, 1, 1) return builder.as_markup() @@ -22,7 +23,8 @@ def build_audit_source_kb() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="Redis raw", callback_data=AdminPanelCallback(action="audit_refresh_redis").pack()) builder.button(text="БД вчера", callback_data=AdminPanelCallback(action="audit_refresh_db").pack()) - builder.adjust(2) + builder.button(text="Администратор", callback_data=AdminPanelCallback(action="admin").pack()) + builder.adjust(2, 1) return builder.as_markup() @@ -30,7 +32,8 @@ def build_audit_reset_confirm_kb(source: str) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="Да, сбросить", callback_data=AdminPanelCallback(action=f"audit_reset_do_{source}").pack()) builder.button(text="Отмена", callback_data=AdminPanelCallback(action=f"audit_refresh_{source}").pack()) - builder.adjust(1) + builder.button(text="Администратор", callback_data=AdminPanelCallback(action="admin").pack()) + builder.adjust(1, 1, 1) return builder.as_markup() diff --git a/handlers/admin/stats/stats_handler.py b/handlers/admin/stats/stats_handler.py index 1695a5e7..de9bf8dc 100644 --- a/handlers/admin/stats/stats_handler.py +++ b/handlers/admin/stats/stats_handler.py @@ -316,74 +316,6 @@ async def handle_stats(callback_query: CallbackQuery, session: AsyncSession): await callback_query.answer("Произошла ошибка при получении статистики", show_alert=True) -@router.callback_query(AdminPanelCallback.filter(F.action == "stats_audit"), IsAdminFilter()) -async def handle_stats_audit(callback_query: CallbackQuery, session: AsyncSession): - """Статистика аудита за вчера (МСК): объём по шагам, % ошибок, воронка.""" - kb = build_admin_back_kb("stats") - try: - moscow_tz = pytz.timezone("Europe/Moscow") - now = datetime.now(moscow_tz) - yesterday_date = (now.date() - timedelta(days=1)) - start = moscow_tz.localize(datetime.combine(yesterday_date, datetime.min.time())) - end = start + timedelta(days=1) - start_utc = start.astimezone(pytz.UTC) - end_utc = end.astimezone(pytz.UTC) - - stats = await get_audit_stats(session, date_from=start_utc, date_to=end_utc) - funnel = await get_audit_funnel(session, date_from=start_utc, date_to=end_utc) - - summary = stats["summary"] - by_path = stats["by_path"] - - lines = [ - f"📊 Аудит за {yesterday_date.strftime('%d.%m.%Y')} (МСК)", - "", - ( - f"📎 Сырых событий: {summary.get('raw_total_events', summary['total_events'])} │ " - f"Аналитических шагов: {summary.get('analytics_total_events', summary['total_events'])} │ " - f"Уникальных пользователей: {summary['unique_users']}" - ), - "", - "По шагам (топ по объёму):", - ] - for row in by_path[:8]: - fail_mark = "⚠️" if row["fail_rate_pct"] > 10 else "✅" - lines.append( - f"{fail_mark} {row['label']}: {row['total']} (ок: {row['success']}, ошибок: {row['fail']}, {row['fail_rate_pct']}% ошибок)" - ) - lines.append("") - _append_key_audit_steps(lines, by_path) - lines.append("") - success_by_step = _audit_success_event_counts(by_path) - pay_start = success_by_step.get("pay_start", 0) - pay_ok = success_by_step.get("pay", 0) - key_created = success_by_step.get("key_create", 0) - connect_opened = success_by_step.get("connect", 0) - pct_pay = round(100.0 * pay_ok / pay_start, 1) if pay_start else 0 - pct_connect = round(100.0 * connect_opened / key_created, 1) if key_created else 0 - lines.append("Оплата: начало {0}, успешная {1}, % успешных от созданных: {2}%".format(pay_start, pay_ok, pct_pay)) - lines.append("Подписка: оформлена {0}, открыто подключение {1}, % от оформленных: {2}%".format(key_created, connect_opened, pct_connect)) - lines.append("") - lines.append("Воронка (уник. пользователей по точным шагам):") - for step in funnel: - conv = f" → {step['conversion_from_prev_pct']}%" if step["conversion_from_prev_pct"] is not None else "" - lines.append(f" • {step['label']}: {step['count']} польз.{conv}") - - text = "\n".join(lines) - await callback_query.message.edit_text( - text, - reply_markup=kb, - ) - await callback_query.answer() - except Exception as e: - logger.exception("Ошибка при получении статистики аудита: %s", e) - await callback_query.answer("Ошибка при загрузке статистики аудита", show_alert=True) - await callback_query.message.edit_text( - f"❗ Ошибка: {e}", - reply_markup=kb, - ) - - async def _build_audit_report(session: AsyncSession, source: str = "db") -> tuple[str | None, str | None]: """Собирает текст отчёта аудита из выбранного источника.""" try: diff --git a/logger.py b/logger.py index d54acefd..63bf0dd4 100644 --- a/logger.py +++ b/logger.py @@ -70,8 +70,18 @@ level_mapping = {50: "CRITICAL", 40: "ERROR", 30: "WARNING", 20: "INFO", 10: "DE class InterceptHandler(logging.Handler): def emit(self, record): + message = record.getMessage() + if ( + record.name.startswith("aiohttp.") + and "Invalid method encountered" in message + and "b'\\x16\\x03\\x01'" in message + ): + logger.opt(depth=6).warning( + "[HTTP] На порт пришли TLS/HTTPS данные вместо HTTP, соединение закрыто" + ) + return logger.opt(depth=6, exception=record.exc_info).log( - level_mapping.get(record.levelno, "INFO"), record.getMessage() + level_mapping.get(record.levelno, "INFO"), message )