From cc57c8610b95512ea14ad48be8ea7b49992f396e Mon Sep 17 00:00:00 2001 From: Vladless Date: Tue, 10 Feb 2026 22:35:39 +0300 Subject: [PATCH] custom emoji in inline buttons/ CLI 0.3.9/ updated dependencies/ other minor changes --- Dockerfile | 3 +++ bot.py | 6 +++++ cli_launcher.py | 26 +++++++++++-------- database/referrals.py | 10 ++++++-- handlers/buttons.py | 9 +++++++ middlewares/__init__.py | 3 +-- middlewares/probe.py | 2 +- pyproject.toml | 16 ++++++------ requirements.txt | 24 +++++++++--------- utils/button_icons.py | 55 ++++++++++++++++++++++++++++++++++++++++ utils/modules_loader.py | 10 ++++++++ utils/modules_manager.py | 5 ++++ 12 files changed, 133 insertions(+), 36 deletions(-) create mode 100644 utils/button_icons.py diff --git a/Dockerfile b/Dockerfile index 92a17fee..207b09d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,4 +20,7 @@ RUN rm -rf /app/venv \ && /app/venv/bin/pip install --upgrade pip \ && /app/venv/bin/pip install -r requirements.txt +RUN adduser --disabled-password --gecos "" appuser && chown -R appuser:appuser /app +USER appuser + CMD ["/app/venv/bin/python", "main.py"] diff --git a/bot.py b/bot.py index 71765595..43d5e8dd 100644 --- a/bot.py +++ b/bot.py @@ -5,10 +5,12 @@ from aiogram.fsm.storage.memory import MemoryStorage from config import API_TOKEN from filters.private import IsPrivateFilter +from utils.button_icons import apply_button_icons_patch, set_button_icon_config from utils.custom_emojis import initialize_custom_emojis from utils.errors import setup_error_handlers from utils.modules_loader import load_modules_from_folder, modules_hub +apply_button_icons_patch() bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) storage = MemoryStorage() @@ -18,6 +20,10 @@ dp.include_router(modules_hub) load_modules_from_folder() +from handlers.buttons import BUTTON_ICON_CONFIG + +set_button_icon_config(BUTTON_ICON_CONFIG) + dp.message.filter(IsPrivateFilter()) dp.callback_query.filter(IsPrivateFilter()) diff --git a/cli_launcher.py b/cli_launcher.py index c9258480..d9c2414c 100755 --- a/cli_launcher.py +++ b/cli_launcher.py @@ -210,7 +210,10 @@ def restore_from_backup(): install_rsync_if_needed() console.print("[yellow]Копирую файлы из бэкапа в проект...[/yellow]") - rc = subprocess.run(f"rsync -a --delete {sel_path}/ {PROJECT_DIR}/", shell=True).returncode + rc = subprocess.run( + ["rsync", "-a", "--delete", f"{sel_path}/", f"{PROJECT_DIR}/"], + check=False, + ).returncode if rc != 0: console.print("[red]❌ Ошибка rsync при восстановлении[/red]") return @@ -239,7 +242,7 @@ def auto_update_cli(): console.print("[green]Доступна новая версия CLI. Обновляю...[/green]") with open(current_path, "w", encoding="utf-8") as f: f.write(latest_text) - os.chmod(current_path, 0o755) + os.chmod(current_path, 0o644) console.print("[green]CLI обновлён. Перезапуск...[/green]") os.execv(sys.executable, [sys.executable, current_path]) else: @@ -370,13 +373,13 @@ def install_dependencies(): shutil.rmtree("venv") console.print("[yellow]Удалён старый venv[/yellow]") - subprocess.run(f"{python312_path} -m venv venv", shell=True, check=True) + subprocess.run([python312_path, "-m", "venv", "venv"], check=True) progress.update(task_id, description="Установка зависимостей...") subprocess.run( - "bash -c 'source venv/bin/activate && pip install -r requirements.txt'", - shell=True, + [os.path.join("venv", "bin", "pip"), "install", "-r", "requirements.txt"], check=True, + cwd=PROJECT_DIR, ) progress.update(task_id, description="Установка завершена") @@ -390,7 +393,7 @@ def restart_service(): if is_service_exists(SERVICE_NAME): console.print("[blue]🚀 Перезапуск службы...[/blue]") with console.status("[bold yellow]Перезапуск...[/bold yellow]"): - subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True) + subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME]) else: console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]") @@ -482,7 +485,8 @@ def update_from_beta(): exclude_options += "--exclude=handlers/buttons.py " exclude_options += "--exclude=modules " - subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True) + rsync_cmd = ["rsync", "-a"] + [x for x in exclude_options.split() if x] + [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"] + subprocess.run(rsync_cmd) modules_path = os.path.join(PROJECT_DIR, "modules") if not os.path.exists(modules_path): @@ -544,8 +548,7 @@ def update_from_release(): console.print(f"[cyan]Клонируем релиз {tag_name} во временную папку...[/cyan]") subprocess.run(["rm", "-rf", TEMP_DIR]) subprocess.run( - f"git clone --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}", - shell=True, + ["git", "clone", "--branch", tag_name, GITHUB_REPO, TEMP_DIR], check=True, ) @@ -560,7 +563,8 @@ def update_from_release(): exclude_options += "--exclude=handlers/buttons.py " exclude_options += "--exclude=modules " - subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True) + rsync_cmd = ["rsync", "-a"] + exclude_options.split() + [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"] + subprocess.run(rsync_cmd) modules_path = os.path.join(PROJECT_DIR, "modules") if not os.path.exists(modules_path): @@ -608,7 +612,7 @@ def show_update_menu(): def show_menu(): - table = Table(title="Solobot CLI v0.3.8", title_style="bold magenta", header_style="bold blue") + table = Table(title="Solobot CLI v0.3.9", 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)") diff --git a/database/referrals.py b/database/referrals.py index 6dd0e00c..07e8ab69 100644 --- a/database/referrals.py +++ b/database/referrals.py @@ -161,7 +161,10 @@ async def get_total_referral_bonus(session: AsyncSession, referrer_tg_id: int, m """ ) - result = await session.execute(text(bonus_query), {"tg_id": referrer_tg_id, "max_levels": max_levels}) + result = await session.execute( + text(bonus_query), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text + {"tg_id": referrer_tg_id, "max_levels": max_levels}, + ) total_bonus_raw = result.scalar() total_bonus = round(float(total_bonus_raw or 0), 2) @@ -189,7 +192,10 @@ async def get_referrals_by_level(session: AsyncSession, referrer_tg_id: int, max GROUP BY level ORDER BY level """ - result = await session.execute(text(query), {"referrer_tg_id": referrer_tg_id, "max_levels": max_levels}) + result = await session.execute( + text(query), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text + {"referrer_tg_id": referrer_tg_id, "max_levels": max_levels}, + ) return { row["level"]: { "total": row["level_count"], diff --git a/handlers/buttons.py b/handlers/buttons.py index ce438829..93dab8fa 100644 --- a/handlers/buttons.py +++ b/handlers/buttons.py @@ -1,3 +1,12 @@ +BUTTON_ICON_CONFIG: dict[str, dict[str, str]] = { + "partner": {"icon_custom_emoji_id": "5310169226856644648", "style": "primary"}, + # Примеры по callback_data: + # "profile": {"icon_custom_emoji_id": "5310169226856644648", "style": "primary"}, # синяя + # "view_keys": {"icon_custom_emoji_id": "5310169226856644648", "style": "success"}, # зеленая + # "pay": {"style": "primary"}, + # "cancel_broadcast": {"style": "danger"}, # красная +} + # Общие кнопки BACK = "⬅️ Назад" APPLY = "✅ Подтвердить" diff --git a/middlewares/__init__.py b/middlewares/__init__.py index 968890a6..3f323477 100644 --- a/middlewares/__init__.py +++ b/middlewares/__init__.py @@ -1,7 +1,6 @@ from collections.abc import Iterable -from aiogram import Dispatcher -from aiogram.dispatcher.middlewares.base import BaseMiddleware +from aiogram import BaseMiddleware, Dispatcher from config import CHANNEL_REQUIRED, DISABLE_DIRECT_START from middlewares.ban_checker import BanCheckerMiddleware diff --git a/middlewares/probe.py b/middlewares/probe.py index 40f3be7e..540ac778 100644 --- a/middlewares/probe.py +++ b/middlewares/probe.py @@ -1,6 +1,6 @@ import time -from aiogram.dispatcher.middlewares.base import BaseMiddleware +from aiogram import BaseMiddleware from logger import logger diff --git a/pyproject.toml b/pyproject.toml index 6fd09c92..8a2b77b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,15 +3,15 @@ name = "Solo_bot" version = "0.0.1" dependencies = [ "aiofiles==24.1.0", - "aiogram==3.13.1", - "aiohappyeyeballs==2.4.3", - "aiohttp==3.10.10", - "aiosignal==1.3.1", + "aiogram>=3.24.0", + "aiohappyeyeballs>=2.5.0", + "aiohttp==3.13.3", + "aiosignal>=1.4.0", "annotated-types==0.7.0", "async-timeout==4.0.3", "asyncpg==0.30.0", "attrs==24.2.0", - "certifi", + "certifi>=2023.5.7,<2024.0.0", # ограничение из aiocryptopay; CVE-2024-39689 исправлен в 2024.7.4 "charset-normalizer==3.4.0", "deprecated==1.2.14", "distro==1.9.0", @@ -23,11 +23,11 @@ dependencies = [ "propcache==0.2.0", "pydantic", "pydantic-core", - "requests==2.32.3", + "requests==2.32.4", "typing-extensions==4.12.2", - "urllib3==2.2.3", + "urllib3==2.6.3", "wrapt==1.16.0", - "yarl==1.15.5", + "yarl>=1.17.0,<2.0", "yookassa==3.3.0", "loguru", "aiocryptopay", diff --git a/requirements.txt b/requirements.txt index 4a3876f8..84536f07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ aiocryptopay==0.4.7 aiofiles==24.1.0 -aiogram==3.13.1 -aiohappyeyeballs==2.4.3 -aiohttp==3.10.10 -aiosignal==1.3.1 +aiogram==3.24.0 +aiohappyeyeballs==2.5.0 +aiohttp==3.13.3 +aiosignal>=1.4.0 alembic==1.16.4 annotated-types==0.7.0 anyio==4.8.0 @@ -13,18 +13,18 @@ asyncpg==0.30.0 attrs==24.2.0 babel==2.17.0 cachetools==5.5.1 -certifi==2023.11.17 +certifi==2023.11.17 # aiocryptopay требует certifi<2024; обновить после выхода совместимой версии aiocryptopay cffi==1.17.1 charset-normalizer==3.4.0 click==8.2.2 cryptography==45.0.5 Deprecated==1.2.14 distro==1.9.0 -fastapi==0.116.1 +fastapi==0.128.7 frozenlist==1.4.1 greenlet==3.1.1 -h11==0.14.0 -httpcore==1.0.7 +h11==0.16.0 +httpcore==1.0.9 httpx==0.27.2 idna==3.10 loguru==0.7.3 @@ -48,19 +48,19 @@ Pygments==2.19.2 python-dateutil==2.9.0.post0 pytz==2025.1 qrcode==8.2 -requests==2.32.3 +requests==2.32.4 rich==14.1.0 robokassa==0.3.2 ruff==0.9.5 six==1.17.0 sniffio==1.3.1 SQLAlchemy==2.0.38 -starlette==0.47.2 +starlette==0.49.1 StrEnum==0.4.15 typing_extensions==4.12.2 tzlocal==5.3.1 -urllib3==2.2.3 +urllib3==2.6.3 uvicorn==0.35.0 wrapt==1.16.0 -yarl==1.15.5 +yarl>=1.17.0,<2.0 yookassa==3.9.0 diff --git a/utils/button_icons.py b/utils/button_icons.py new file mode 100644 index 00000000..459291a7 --- /dev/null +++ b/utils/button_icons.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import aiogram.types + +_OriginalInlineKeyboardButton = aiogram.types.InlineKeyboardButton + +_button_icon_config: dict[str, dict[str, str]] = {} + + +def apply_button_icons_patch(config: dict[str, dict[str, str]] | None = None) -> None: + """ + Патчит InlineKeyboardButton, добавляя поддержку глобального конфига для иконок и стилей кнопок по callback_data или url. + """ + if config is not None: + _button_icon_config.clear() + _button_icon_config.update(config) + + class _PatchedInlineKeyboardButton(_OriginalInlineKeyboardButton): + def __init__(self, **kwargs: object): + key = kwargs.get("callback_data") or kwargs.get("url") + if key is not None and isinstance(key, str) and key in _button_icon_config: + kwargs = {**kwargs, **_button_icon_config[key]} + super().__init__(**kwargs) + + aiogram.types.InlineKeyboardButton = _PatchedInlineKeyboardButton + + +def set_button_icon_config(config: dict[str, dict[str, str]]) -> None: + """Подставить конфиг кнопок (вызвать после загрузки handlers, из handlers.buttons.BUTTON_ICON_CONFIG).""" + _button_icon_config.clear() + _button_icon_config.update(config) + + +def inline_button( + text: str, + callback_data: str | None = None, + url: str | None = None, + web_app: object | None = None, + *, + icon_custom_emoji_id: str | None = None, + style: str | None = None, +) -> _OriginalInlineKeyboardButton: + """Собирает InlineKeyboardButton с опциональной иконкой (custom emoji) и стилем.""" + kwargs: dict = {"text": text} + if callback_data is not None: + kwargs["callback_data"] = callback_data + if url is not None: + kwargs["url"] = url + if web_app is not None: + kwargs["web_app"] = web_app + if icon_custom_emoji_id is not None: + kwargs["icon_custom_emoji_id"] = icon_custom_emoji_id + if style is not None: + kwargs["style"] = style + return aiogram.types.InlineKeyboardButton(**kwargs) diff --git a/utils/modules_loader.py b/utils/modules_loader.py index af3ba412..ecd0014e 100644 --- a/utils/modules_loader.py +++ b/utils/modules_loader.py @@ -12,12 +12,18 @@ from .modules_manager import manager modules_hub = Router(name="modules_hub") +def _is_safe_module_name(name: str) -> bool: + return bool(name and name.isidentifier() and "." not in name and "/" not in name and "\\" not in name) + def load_modules_from_folder(folder: str = "modules") -> list[Router]: routers = [] base_path = Path(folder) for _finder, name, _ispkg in pkgutil.iter_modules([str(base_path)]): + if not _is_safe_module_name(name): + logger.warning(f"[Modules] Пропуск недопустимого имени модуля: {name!r}") + continue if not manager.should_autostart(name): logger.info(f"[Modules] Пропуск автозапуска модуля '{name}' (отключён).") continue @@ -43,6 +49,8 @@ def load_module_webhooks(folder: str = "modules") -> list[dict]: base_path = Path(folder) for _finder, name, _ispkg in pkgutil.iter_modules([str(base_path)]): + if not _is_safe_module_name(name): + continue if not manager.should_autostart(name): logger.info(f"[Modules] Пропуск вебхуков модуля '{name}' (отключён).") continue @@ -65,6 +73,8 @@ def load_module_fast_flow_handlers(folder: str = "modules") -> dict: base_path = Path(folder) for _finder, name, _ispkg in pkgutil.iter_modules([str(base_path)]): + if not _is_safe_module_name(name): + continue if not manager.should_autostart(name): logger.info(f"[Modules] Пропуск fast-flow модуля '{name}' (отключён).") continue diff --git a/utils/modules_manager.py b/utils/modules_manager.py index b912dc95..7a2e8a84 100644 --- a/utils/modules_manager.py +++ b/utils/modules_manager.py @@ -57,7 +57,12 @@ class ModulesManager: rec.enabled = True self.registry[name] = rec + def _is_safe_module_name(self, name: str) -> bool: + return bool(name and name.isidentifier() and "." not in name and "/" not in name and "\\" not in name) + async def start(self, name: str) -> None: + if not self._is_safe_module_name(name): + raise ValueError(f"[Modules] Недопустимое имя модуля: {name!r}") rec = self.registry.get(name) or ModuleRecord(name, self.pkg(name)) if rec.enabled: logger.info(f"[Modules] {name} уже активен.")