custom emoji in inline buttons/ CLI 0.3.9/ updated dependencies/ other minor changes

This commit is contained in:
Vladless
2026-02-10 22:35:39 +03:00
parent 3f8db29405
commit cc57c8610b
12 changed files with 133 additions and 36 deletions
+3
View File
@@ -20,4 +20,7 @@ RUN rm -rf /app/venv \
&& /app/venv/bin/pip install --upgrade pip \ && /app/venv/bin/pip install --upgrade pip \
&& /app/venv/bin/pip install -r requirements.txt && /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"] CMD ["/app/venv/bin/python", "main.py"]
+6
View File
@@ -5,10 +5,12 @@ from aiogram.fsm.storage.memory import MemoryStorage
from config import API_TOKEN from config import API_TOKEN
from filters.private import IsPrivateFilter 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.custom_emojis import initialize_custom_emojis
from utils.errors import setup_error_handlers from utils.errors import setup_error_handlers
from utils.modules_loader import load_modules_from_folder, modules_hub 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)) bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
storage = MemoryStorage() storage = MemoryStorage()
@@ -18,6 +20,10 @@ dp.include_router(modules_hub)
load_modules_from_folder() load_modules_from_folder()
from handlers.buttons import BUTTON_ICON_CONFIG
set_button_icon_config(BUTTON_ICON_CONFIG)
dp.message.filter(IsPrivateFilter()) dp.message.filter(IsPrivateFilter())
dp.callback_query.filter(IsPrivateFilter()) dp.callback_query.filter(IsPrivateFilter())
+15 -11
View File
@@ -210,7 +210,10 @@ def restore_from_backup():
install_rsync_if_needed() install_rsync_if_needed()
console.print("[yellow]Копирую файлы из бэкапа в проект...[/yellow]") 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: if rc != 0:
console.print("[red]❌ Ошибка rsync при восстановлении[/red]") console.print("[red]❌ Ошибка rsync при восстановлении[/red]")
return return
@@ -239,7 +242,7 @@ def auto_update_cli():
console.print("[green]Доступна новая версия CLI. Обновляю...[/green]") console.print("[green]Доступна новая версия CLI. Обновляю...[/green]")
with open(current_path, "w", encoding="utf-8") as f: with open(current_path, "w", encoding="utf-8") as f:
f.write(latest_text) f.write(latest_text)
os.chmod(current_path, 0o755) os.chmod(current_path, 0o644)
console.print("[green]CLI обновлён. Перезапуск...[/green]") console.print("[green]CLI обновлён. Перезапуск...[/green]")
os.execv(sys.executable, [sys.executable, current_path]) os.execv(sys.executable, [sys.executable, current_path])
else: else:
@@ -370,13 +373,13 @@ def install_dependencies():
shutil.rmtree("venv") shutil.rmtree("venv")
console.print("[yellow]Удалён старый venv[/yellow]") 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="Установка зависимостей...") progress.update(task_id, description="Установка зависимостей...")
subprocess.run( subprocess.run(
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'", [os.path.join("venv", "bin", "pip"), "install", "-r", "requirements.txt"],
shell=True,
check=True, check=True,
cwd=PROJECT_DIR,
) )
progress.update(task_id, description="Установка завершена") progress.update(task_id, description="Установка завершена")
@@ -390,7 +393,7 @@ def restart_service():
if is_service_exists(SERVICE_NAME): if is_service_exists(SERVICE_NAME):
console.print("[blue]🚀 Перезапуск службы...[/blue]") console.print("[blue]🚀 Перезапуск службы...[/blue]")
with console.status("[bold yellow]Перезапуск...[/bold yellow]"): 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: else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]") console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
@@ -482,7 +485,8 @@ def update_from_beta():
exclude_options += "--exclude=handlers/buttons.py " exclude_options += "--exclude=handlers/buttons.py "
exclude_options += "--exclude=modules " 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") modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path): if not os.path.exists(modules_path):
@@ -544,8 +548,7 @@ def update_from_release():
console.print(f"[cyan]Клонируем релиз {tag_name} во временную папку...[/cyan]") console.print(f"[cyan]Клонируем релиз {tag_name} во временную папку...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR]) subprocess.run(["rm", "-rf", TEMP_DIR])
subprocess.run( subprocess.run(
f"git clone --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}", ["git", "clone", "--branch", tag_name, GITHUB_REPO, TEMP_DIR],
shell=True,
check=True, check=True,
) )
@@ -560,7 +563,8 @@ def update_from_release():
exclude_options += "--exclude=handlers/buttons.py " exclude_options += "--exclude=handlers/buttons.py "
exclude_options += "--exclude=modules " 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") modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path): if not os.path.exists(modules_path):
@@ -608,7 +612,7 @@ def show_update_menu():
def show_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("", justify="center", style="cyan", no_wrap=True)
table.add_column("Операция", style="white") table.add_column("Операция", style="white")
table.add_row("1", "Запустить бота (systemd)") table.add_row("1", "Запустить бота (systemd)")
+8 -2
View File
@@ -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_raw = result.scalar()
total_bonus = round(float(total_bonus_raw or 0), 2) 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 GROUP BY level
ORDER 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 { return {
row["level"]: { row["level"]: {
"total": row["level_count"], "total": row["level_count"],
+9
View File
@@ -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 = "⬅️ Назад" BACK = "⬅️ Назад"
APPLY = "✅ Подтвердить" APPLY = "✅ Подтвердить"
+1 -2
View File
@@ -1,7 +1,6 @@
from collections.abc import Iterable from collections.abc import Iterable
from aiogram import Dispatcher from aiogram import BaseMiddleware, Dispatcher
from aiogram.dispatcher.middlewares.base import BaseMiddleware
from config import CHANNEL_REQUIRED, DISABLE_DIRECT_START from config import CHANNEL_REQUIRED, DISABLE_DIRECT_START
from middlewares.ban_checker import BanCheckerMiddleware from middlewares.ban_checker import BanCheckerMiddleware
+1 -1
View File
@@ -1,6 +1,6 @@
import time import time
from aiogram.dispatcher.middlewares.base import BaseMiddleware from aiogram import BaseMiddleware
from logger import logger from logger import logger
+8 -8
View File
@@ -3,15 +3,15 @@ name = "Solo_bot"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"aiofiles==24.1.0", "aiofiles==24.1.0",
"aiogram==3.13.1", "aiogram>=3.24.0",
"aiohappyeyeballs==2.4.3", "aiohappyeyeballs>=2.5.0",
"aiohttp==3.10.10", "aiohttp==3.13.3",
"aiosignal==1.3.1", "aiosignal>=1.4.0",
"annotated-types==0.7.0", "annotated-types==0.7.0",
"async-timeout==4.0.3", "async-timeout==4.0.3",
"asyncpg==0.30.0", "asyncpg==0.30.0",
"attrs==24.2.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", "charset-normalizer==3.4.0",
"deprecated==1.2.14", "deprecated==1.2.14",
"distro==1.9.0", "distro==1.9.0",
@@ -23,11 +23,11 @@ dependencies = [
"propcache==0.2.0", "propcache==0.2.0",
"pydantic", "pydantic",
"pydantic-core", "pydantic-core",
"requests==2.32.3", "requests==2.32.4",
"typing-extensions==4.12.2", "typing-extensions==4.12.2",
"urllib3==2.2.3", "urllib3==2.6.3",
"wrapt==1.16.0", "wrapt==1.16.0",
"yarl==1.15.5", "yarl>=1.17.0,<2.0",
"yookassa==3.3.0", "yookassa==3.3.0",
"loguru", "loguru",
"aiocryptopay", "aiocryptopay",
+12 -12
View File
@@ -1,9 +1,9 @@
aiocryptopay==0.4.7 aiocryptopay==0.4.7
aiofiles==24.1.0 aiofiles==24.1.0
aiogram==3.13.1 aiogram==3.24.0
aiohappyeyeballs==2.4.3 aiohappyeyeballs==2.5.0
aiohttp==3.10.10 aiohttp==3.13.3
aiosignal==1.3.1 aiosignal>=1.4.0
alembic==1.16.4 alembic==1.16.4
annotated-types==0.7.0 annotated-types==0.7.0
anyio==4.8.0 anyio==4.8.0
@@ -13,18 +13,18 @@ asyncpg==0.30.0
attrs==24.2.0 attrs==24.2.0
babel==2.17.0 babel==2.17.0
cachetools==5.5.1 cachetools==5.5.1
certifi==2023.11.17 certifi==2023.11.17 # aiocryptopay требует certifi<2024; обновить после выхода совместимой версии aiocryptopay
cffi==1.17.1 cffi==1.17.1
charset-normalizer==3.4.0 charset-normalizer==3.4.0
click==8.2.2 click==8.2.2
cryptography==45.0.5 cryptography==45.0.5
Deprecated==1.2.14 Deprecated==1.2.14
distro==1.9.0 distro==1.9.0
fastapi==0.116.1 fastapi==0.128.7
frozenlist==1.4.1 frozenlist==1.4.1
greenlet==3.1.1 greenlet==3.1.1
h11==0.14.0 h11==0.16.0
httpcore==1.0.7 httpcore==1.0.9
httpx==0.27.2 httpx==0.27.2
idna==3.10 idna==3.10
loguru==0.7.3 loguru==0.7.3
@@ -48,19 +48,19 @@ Pygments==2.19.2
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
pytz==2025.1 pytz==2025.1
qrcode==8.2 qrcode==8.2
requests==2.32.3 requests==2.32.4
rich==14.1.0 rich==14.1.0
robokassa==0.3.2 robokassa==0.3.2
ruff==0.9.5 ruff==0.9.5
six==1.17.0 six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
SQLAlchemy==2.0.38 SQLAlchemy==2.0.38
starlette==0.47.2 starlette==0.49.1
StrEnum==0.4.15 StrEnum==0.4.15
typing_extensions==4.12.2 typing_extensions==4.12.2
tzlocal==5.3.1 tzlocal==5.3.1
urllib3==2.2.3 urllib3==2.6.3
uvicorn==0.35.0 uvicorn==0.35.0
wrapt==1.16.0 wrapt==1.16.0
yarl==1.15.5 yarl>=1.17.0,<2.0
yookassa==3.9.0 yookassa==3.9.0
+55
View File
@@ -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)
+10
View File
@@ -12,12 +12,18 @@ from .modules_manager import manager
modules_hub = Router(name="modules_hub") 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]: def load_modules_from_folder(folder: str = "modules") -> list[Router]:
routers = [] routers = []
base_path = Path(folder) base_path = Path(folder)
for _finder, name, _ispkg in pkgutil.iter_modules([str(base_path)]): 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): if not manager.should_autostart(name):
logger.info(f"[Modules] Пропуск автозапуска модуля '{name}' (отключён).") logger.info(f"[Modules] Пропуск автозапуска модуля '{name}' (отключён).")
continue continue
@@ -43,6 +49,8 @@ def load_module_webhooks(folder: str = "modules") -> list[dict]:
base_path = Path(folder) base_path = Path(folder)
for _finder, name, _ispkg in pkgutil.iter_modules([str(base_path)]): 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): if not manager.should_autostart(name):
logger.info(f"[Modules] Пропуск вебхуков модуля '{name}' (отключён).") logger.info(f"[Modules] Пропуск вебхуков модуля '{name}' (отключён).")
continue continue
@@ -65,6 +73,8 @@ def load_module_fast_flow_handlers(folder: str = "modules") -> dict:
base_path = Path(folder) base_path = Path(folder)
for _finder, name, _ispkg in pkgutil.iter_modules([str(base_path)]): 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): if not manager.should_autostart(name):
logger.info(f"[Modules] Пропуск fast-flow модуля '{name}' (отключён).") logger.info(f"[Modules] Пропуск fast-flow модуля '{name}' (отключён).")
continue continue
+5
View File
@@ -57,7 +57,12 @@ class ModulesManager:
rec.enabled = True rec.enabled = True
self.registry[name] = rec 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: 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)) rec = self.registry.get(name) or ModuleRecord(name, self.pkg(name))
if rec.enabled: if rec.enabled:
logger.info(f"[Modules] {name} уже активен.") logger.info(f"[Modules] {name} уже активен.")