Module management/ TRIBUTE webapp/ small gift fix/ backup selection in CLI

This commit is contained in:
Vladless
2025-10-07 02:48:34 +03:00
parent 23e01da0fd
commit b09bde32e4
22 changed files with 591 additions and 90 deletions
+2 -1
View File
@@ -59,4 +59,5 @@ Dockerfile
setup.py
.ruff_cache
.github/workflows/
modules/
modules/
storage/
+3 -4
View File
@@ -13,17 +13,16 @@ from config import ADMIN_ID, API_TOKEN
from database import async_session_maker
from filters.private import IsPrivateFilter
from logger import logger
from utils.modules_loader import load_modules_from_folder
from utils.modules_loader import load_modules_from_folder, modules_hub
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
dp.include_router(modules_hub)
for mod_router in load_modules_from_folder():
dp.include_router(mod_router)
load_modules_from_folder()
dp.message.filter(IsPrivateFilter())
dp.callback_query.filter(IsPrivateFilter())
+60 -18
View File
@@ -51,11 +51,10 @@ try:
except Exception:
pass
console = Console()
ensure_utf8_locale()
console = Console()
BACK_DIR = os.path.expanduser("~/.solobot_backup")
BACK_DIR = os.path.expanduser("~/.solobot_backups")
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
IS_ROOT_DIR = PROJECT_DIR == "/root"
@@ -97,29 +96,74 @@ def print_logo():
console.print(f"[bold green]Директория бота:[/bold green] [yellow]{PROJECT_DIR}[/yellow]\n")
def list_backups():
if not os.path.isdir(BACK_DIR):
return []
pairs = []
for name in os.listdir(BACK_DIR):
path = os.path.join(BACK_DIR, name)
if os.path.isdir(path):
try:
mtime = os.path.getmtime(path)
except Exception:
mtime = 0
pairs.append((mtime, path))
pairs.sort(reverse=True)
return [p for _, p in pairs]
def prune_old_backups():
backups = list_backups()
for path in backups[3:]:
try:
shutil.rmtree(path, ignore_errors=True)
except Exception:
subprocess.run(["sudo", "rm", "-rf", path])
def backup_project():
from datetime import datetime
os.makedirs(BACK_DIR, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
dst = os.path.join(BACK_DIR, f"backup-{ts}")
console.print("[yellow]Создаётся резервная копия проекта...[/yellow]")
with console.status("[bold cyan]Копирование файлов...[/bold cyan]"):
subprocess.run(["rm", "-rf", BACK_DIR])
subprocess.run(["cp", "-r", PROJECT_DIR, BACK_DIR])
console.print(f"[green]Бэкап сохранён в: {BACK_DIR}[/green]")
subprocess.run(["cp", "-r", PROJECT_DIR, dst])
console.print(f"[green]Бэкап сохранён в: {dst}[/green]")
prune_old_backups()
def restore_from_backup():
if not os.path.isdir(BACK_DIR):
console.print(f"[red]❌ Бэкап не найден: {BACK_DIR}[/red]")
from datetime import datetime
backups = list_backups()[:3]
if not backups:
console.print(f"[red]❌ Бэкапы не найдены: {BACK_DIR}[/red]")
return
console.print("\n[bold green]Доступные бэкапы:[/bold green]")
shown = []
for idx, path in enumerate(backups, 1):
try:
mtime = os.path.getmtime(path)
dt = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
dt = "unknown"
console.print(f"[cyan]{idx}.[/cyan] {os.path.basename(path)} [dim]{dt}[/dim]")
shown.append((idx, path))
try:
mtime = os.path.getmtime(BACK_DIR)
from datetime import datetime
dt = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
console.print(f"[cyan]Обнаружен бэкап от: {dt}[/cyan]")
choice = Prompt.ask(
"[bold blue]Выберите номер бэкапа[/bold blue]",
choices=[str(i) for i, _ in shown],
)
except Exception:
pass
return
console.print("[red]Внимание: текущие файлы проекта будут перезаписаны содержимым бэкапа.[/red]")
sel_path = shown[int(choice) - 1][1]
console.print("[red]Внимание: текущие файлы проекта будут перезаписаны выбранным бэкапом.[/red]")
if not Confirm.ask("[yellow]Продолжить восстановление из бэкапа?[/yellow]"):
return
@@ -130,7 +174,7 @@ def restore_from_backup():
install_rsync_if_needed()
console.print("[yellow]Копирую файлы из бэкапа в проект...[/yellow]")
rc = subprocess.run(f"rsync -a --delete {BACK_DIR}/ {PROJECT_DIR}/", shell=True).returncode
rc = subprocess.run(f"rsync -a --delete {sel_path}/ {PROJECT_DIR}/", shell=True).returncode
if rc != 0:
console.print("[red]❌ Ошибка rsync при восстановлении[/red]")
return
@@ -142,7 +186,6 @@ def restore_from_backup():
def auto_update_cli():
"""Обновляет CLI, если отличается от последней версии. Перезапускает при необходимости."""
console.print("[yellow]Проверка обновлений CLI...[/yellow]")
try:
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
@@ -170,7 +213,6 @@ def auto_update_cli():
def fix_permissions():
"""Устанавливает корректные права на все файлы и папки проекта"""
console.print("[yellow]Восстанавливаю владельца и права доступа к проекту...[/yellow]")
try:
+2
View File
@@ -9,6 +9,7 @@ from .clusters import router as clusters_router
from .coupons import router as coupons_router
from .gifts import router as gifts_router
from .management import router as management_router
from .module import router as modules_router
from .panel import router as panel_router
from .restart import router as restart_router
from .sender import router as sender_router
@@ -21,6 +22,7 @@ from .users import router as users_router
router = Router(name="admins_main_router")
router.include_routers(
modules_router,
panel_router,
management_router,
servers_router,
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .module_handler import router
+88
View File
@@ -0,0 +1,88 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.admin.panel.keyboard import AdminPanelCallback
from handlers.buttons import BACK
from utils.modules_manager import manager
def build_modules_kb(page: int, total_pages: int, items: list[tuple[str, str | None]]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for name, _ in items:
label = name if manager.is_enabled(name) else f"{name} (off)"
builder.button(
text=label,
callback_data=AdminPanelCallback(action=f"module__{name}", page=page).pack(),
)
if items:
builder.adjust(2)
if total_pages > 1:
row = []
if page > 1:
row.append(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminPanelCallback(action="modules", page=page - 1).pack(),
)
)
row.append(
InlineKeyboardButton(
text=f"{page}/{total_pages}",
callback_data=AdminPanelCallback(action="modules", page=page).pack(),
)
)
if page < total_pages:
row.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=AdminPanelCallback(action="modules", page=page + 1).pack(),
)
)
builder.row(*row)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminPanelCallback(action="admin", page=1).pack(),
)
)
return builder.as_markup()
def build_module_menu_kb(name: str, page: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
enabled = manager.is_enabled(name)
if enabled:
builder.button(
text="🔁 Перезапустить",
callback_data=AdminPanelCallback(action=f"module_restart__{name}", page=page).pack(),
)
builder.button(
text="🛑 Остановить",
callback_data=AdminPanelCallback(action=f"module_stop__{name}", page=page).pack(),
)
else:
builder.button(
text="▶️ Запустить",
callback_data=AdminPanelCallback(action=f"module_start__{name}", page=page).pack(),
)
builder.button(
text="🔄 Обновить",
callback_data=AdminPanelCallback(action=f"module_update__{name}", page=page).pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ К списку",
callback_data=AdminPanelCallback(action="modules", page=page).pack(),
)
)
builder.adjust(1)
return builder.as_markup()
+167
View File
@@ -0,0 +1,167 @@
import os
from math import ceil
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from filters.admin import IsAdminFilter
from handlers.admin.panel.keyboard import AdminPanelCallback
from utils.modules_manager import manager
from .keyboard import build_module_menu_kb, build_modules_kb
router = Router()
def list_installed_modules() -> list[tuple[str, str | None]]:
base = "modules"
if not os.path.isdir(base):
return []
items: list[tuple[str, str | None]] = []
for name in sorted(os.listdir(base)):
path = os.path.join(base, name)
if os.path.isdir(path) and not name.startswith("."):
ver = None
vp = os.path.join(path, "VERSION")
if os.path.isfile(vp):
try:
with open(vp, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
ver = line
break
except Exception:
ver = None
items.append((name, ver))
return items
@router.callback_query(AdminPanelCallback.filter(F.action == "modules"), IsAdminFilter())
async def handle_modules(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
data = callback_query.data or ""
try:
page = int(data.split("page=")[-1].split("&")[0]) if "page=" in data else 1
except Exception:
page = 1
all_items = list_installed_modules()
items = [(n, v) for n, v in all_items if n != "web_admin_panel"]
per_page = 12
total_pages = max(1, (len(items) + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
start = (page - 1) * per_page
chunk = items[start : start + per_page]
if chunk:
def fmt(n, v):
return f"{n} v{v}" if v else n
lines = "\n".join(f"{fmt(n, v)}" for n, v in chunk)
text = f"🧩 Мои модули\n\nНайдено: {len(items)}\n<blockquote>{lines}</blockquote>"
else:
text = "🧩 Мои модули\n\nМодулей не найдено."
markup = build_modules_kb(page, total_pages, chunk)
try:
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
return
raise
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("module_restart__")), IsAdminFilter())
async def handle_module_restart(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
packed = AdminPanelCallback.unpack(callback_query.data)
action = packed.action
page = packed.page or 1
name = action.split("module_restart__", 1)[-1]
try:
await manager.restart(name)
result = "✅ Модуль перезапущен."
except Exception as e:
result = f"❌ Ошибка перезапуска: {e}"
items = dict(list_installed_modules())
ver = items.get(name)
title = f"{name} v{ver}" if ver else name
text = f"🧩 Модуль: <b>{title}</b>\n\n{result}"
markup = build_module_menu_kb(name, page)
try:
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
return
raise
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("module_stop__")), IsAdminFilter())
async def handle_module_stop(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
packed = AdminPanelCallback.unpack(callback_query.data)
action = packed.action
page = packed.page or 1
name = action.split("module_stop__", 1)[-1]
try:
await manager.stop(name)
result = "🛑 Модуль остановлен."
except Exception as e:
result = f"❌ Ошибка остановки: {e}"
items = dict(list_installed_modules())
ver = items.get(name)
title = f"{name} v{ver}" if ver else name
text = f"🧩 Модуль: <b>{title}</b>\n\n{result}"
markup = build_module_menu_kb(name, page)
try:
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
return
raise
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("module_start__")), IsAdminFilter())
async def handle_module_start(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
packed = AdminPanelCallback.unpack(callback_query.data)
action = packed.action
page = packed.page or 1
name = action.split("module_start__", 1)[-1]
try:
await manager.start(name)
result = "▶️ Модуль запущен."
except Exception as e:
result = f"❌ Ошибка запуска: {e}"
items = dict(list_installed_modules())
ver = items.get(name)
title = f"{name} v{ver}" if ver else name
text = f"🧩 Модуль: <b>{title}</b>\n\n{result}"
markup = build_module_menu_kb(name, page)
try:
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
return
raise
+6 -2
View File
@@ -47,6 +47,10 @@ async def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
InlineKeyboardButton(text="💸 Тарифы", callback_data=AdminPanelCallback(action="tariffs").pack()),
InlineKeyboardButton(text="🎁 Подарки", callback_data=AdminPanelCallback(action="gifts").pack()),
)
builder.button(
text="🧩 Мои модули",
callback_data=AdminPanelCallback(action="modules").pack(),
)
builder.button(
text="🤖 Управление ботом",
callback_data=AdminPanelCallback(action="management").pack(),
@@ -70,9 +74,9 @@ async def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
)
if admin_role == "superadmin":
builder.adjust(1, 1, 1, 1, 2, 2, 1, 2, 1)
builder.adjust(1, 1, 1, 1, 2, 2, 1, 1, 2, 1)
else:
builder.adjust(1, 1, 1, 2, 1, 1)
builder.adjust(1, 1, 1, 2, 1, 1, 1)
return builder.as_markup()
+4 -4
View File
@@ -109,10 +109,10 @@ async def process_windows_menu(callback_query: CallbackQuery, session: Any):
builder.row(InlineKeyboardButton(text=DOWNLOAD_PC_BUTTON, url=DOWNLOAD_PC))
if key_link and "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe='')
processed_link = urllib.parse.quote(key_link, safe="")
else:
processed_link = key_link
builder.row(InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=f"{CONNECT_WINDOWS}{processed_link}"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
@@ -140,10 +140,10 @@ async def process_macos_menu(callback_query: CallbackQuery, session: Any):
builder.row(InlineKeyboardButton(text=DOWNLOAD_MACOS_BUTTON, url=DOWNLOAD_MACOS))
if key_link and "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe='')
processed_link = urllib.parse.quote(key_link, safe="")
else:
processed_link = key_link
builder.row(InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=f"{CONNECT_MACOS}{processed_link}"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
+6 -6
View File
@@ -108,10 +108,10 @@ async def process_callback_connect_phone(callback_query: CallbackQuery, session:
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
if key_link and "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe='')
processed_link = urllib.parse.quote(key_link, safe="")
else:
processed_link = key_link
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{processed_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{processed_link}"),
@@ -148,10 +148,10 @@ async def process_callback_connect_ios(callback_query: CallbackQuery, session: A
builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
if key_link and "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe='')
processed_link = urllib.parse.quote(key_link, safe="")
else:
processed_link = key_link
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{processed_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
@@ -186,10 +186,10 @@ async def process_callback_connect_android(callback_query: CallbackQuery, sessio
builder.row(InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID))
if key_link and "happ://crypt" in key_link:
processed_link = urllib.parse.quote(key_link, safe='')
processed_link = urllib.parse.quote(key_link, safe="")
else:
processed_link = key_link
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{processed_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
+1 -1
View File
@@ -29,9 +29,9 @@ from handlers.buttons import (
MAIN_MENU,
MY_SUB,
PC_BUTTON,
ROUTER_BUTTON,
SUPPORT,
TV_BUTTON,
ROUTER_BUTTON
)
from handlers.keys.operations import create_key_on_cluster
from handlers.texts import key_message_success
+9 -10
View File
@@ -168,17 +168,17 @@ async def handle_key_creation(
try:
hook_results = await run_hooks(
"purchase_tariff_group_override",
chat_id=tg_id,
admin=False,
"purchase_tariff_group_override",
chat_id=tg_id,
admin=False,
session=session,
original_group=group_code
original_group=group_code,
)
for hook_result in hook_results:
if hook_result.get("override_group"):
group_code = hook_result["override_group"]
logger.info(f"[PURCHASE] Тарифная группа переопределена хуком: {group_code}")
if hook_result.get("discount_info"):
await state.update_data(discount_info=hook_result["discount_info"])
break
@@ -392,11 +392,11 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
try:
hook_results = await run_hooks(
"check_discount_validity",
chat_id=tg_id,
admin=False,
"check_discount_validity",
chat_id=tg_id,
admin=False,
session=session,
tariff_group=tariff.get("group_code")
tariff_group=tariff.get("group_code"),
)
for hook_result in hook_results:
if not hook_result.get("valid", True):
@@ -412,7 +412,6 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state:
except Exception as e:
logger.warning(f"[PURCHASE] Ошибка при проверке скидок через хуки: {e}")
duration_days = tariff["duration_days"]
price_rub = tariff["price_rub"]
+4 -15
View File
@@ -100,17 +100,14 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
try:
hook_results = await run_hooks(
"renewal_forbidden_groups",
chat_id=tg_id,
admin=False,
session=session
"renewal_forbidden_groups", chat_id=tg_id, admin=False, session=session
)
for hook_result in hook_results:
additional_groups = hook_result.get("additional_groups", [])
forbidden_groups.extend(additional_groups)
except Exception as e:
logger.warning(f"[RENEW] Ошибка при получении дополнительных групп: {e}")
if current_tariff["group_code"] not in forbidden_groups:
group_code = current_tariff["group_code"]
@@ -121,11 +118,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo
try:
hook_results = await run_hooks(
"purchase_tariff_group_override",
chat_id=tg_id,
admin=False,
session=session,
original_group=group_code
"purchase_tariff_group_override", chat_id=tg_id, admin=False, session=session, original_group=group_code
)
for hook_result in hook_results:
if hook_result.get("override_group"):
@@ -268,11 +261,7 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont
try:
hook_results = await run_hooks(
"purchase_tariff_group_override",
chat_id=tg_id,
admin=False,
session=session,
original_group=group_code
"purchase_tariff_group_override", chat_id=tg_id, admin=False, session=session, original_group=group_code
)
for hook_result in hook_results:
if hook_result.get("override_group"):
+1 -1
View File
@@ -20,6 +20,7 @@ from config import (
CONNECT_PHONE_BUTTON,
ENABLE_DELETE_KEY_BUTTON,
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
HAPP_CRYPTOLINK,
HWID_RESET_BUTTON,
QRCODE,
REMNAWAVE_LOGIN,
@@ -28,7 +29,6 @@ from config import (
RENEW_BUTTON_BEFORE_DAYS,
TOGGLE_CLIENT,
USE_COUNTRY_SELECTION,
HAPP_CRYPTOLINK
)
from database import get_key_details, get_keys, get_servers, get_tariff_by_id
from database.models import Key
@@ -514,18 +514,13 @@ async def process_auto_renew_or_notify(
forbidden_groups = ["discounts", "discounts_max", "gifts", "trial"]
try:
hook_results = await run_hooks(
"renewal_forbidden_groups",
chat_id=tg_id,
admin=False,
session=conn
)
hook_results = await run_hooks("renewal_forbidden_groups", chat_id=tg_id, admin=False, session=conn)
for hook_result in hook_results:
additional_groups = hook_result.get("additional_groups", [])
forbidden_groups.extend(additional_groups)
except Exception as e:
logger.warning(f"[AUTO_RENEW] Ошибка при получении дополнительных групп: {e}")
if current_tariff["group_code"] in forbidden_groups:
cluster_tariffs = [t for t in tariffs if t["is_active"] and balance >= t["price_rub"]]
if cluster_tariffs:
+11 -1
View File
@@ -4,6 +4,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.texts import FAST_PAY_NOT_ENOUGH
from handlers.buttons import RUB_CURRENCY, USD_CURRENCY, STARS, MAIN_MENU
from config import TRIBUTE_LINK
from .currency_rates import format_for_user
@@ -11,8 +12,17 @@ def build_currency_choice_kb(show_stars: bool, *, prefix: str = "choose_payment_
kb = InlineKeyboardBuilder()
kb.row(InlineKeyboardButton(text=RUB_CURRENCY, callback_data=f"{prefix}|RUB"))
kb.row(InlineKeyboardButton(text=USD_CURRENCY, callback_data=f"{prefix}|USD"))
trib = (TRIBUTE_LINK or "").strip()
if show_stars:
kb.row(InlineKeyboardButton(text=STARS, callback_data=f"{prefix}|STARS"))
row = [InlineKeyboardButton(text=STARS, callback_data=f"{prefix}|STARS")]
if trib.startswith("https://"):
row.append(InlineKeyboardButton(text="TRIBUTE", url=trib))
kb.row(*row)
else:
if trib.startswith("https://"):
kb.row(InlineKeyboardButton(text="TRIBUTE", url=trib))
kb.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
return kb
+41 -12
View File
@@ -1,24 +1,55 @@
import inspect
from collections.abc import Awaitable, Callable
from collections.abc import Callable
from typing import Any
from logger import logger
_hooks: dict[str, list[Callable[..., Any]]] = {}
_hooks: dict[str, list[tuple[Callable[..., Any], str | None]]] = {}
def register_hook(name: str, func: Callable[..., Any]):
if name not in _hooks:
_hooks[name] = []
_hooks[name].append(func)
def owner(func: Callable[..., Any]) -> str | None:
m = getattr(func, "__module__", "") or ""
if m.startswith("modules."):
parts = m.split(".")
return parts[1] if len(parts) > 1 else None
return None
def register_hook(name: str, func: Callable[..., Any] | None = None):
if func is None:
def deco(f: Callable[..., Any]):
_hooks.setdefault(name, []).append((f, owner(f)))
logger.info(f"[Hook] Зарегистрирован хук '{name}': {f.__name__}")
return f
return deco
_hooks.setdefault(name, []).append((func, owner(func)))
logger.info(f"[Hook] Зарегистрирован хук '{name}': {func.__name__}")
async def run_hooks(name: str, **kwargs) -> list[Any]:
results = []
for func in _hooks.get(name, []):
def unregister_module_hooks(module_name: str):
for k, lst in list(_hooks.items()):
filtered = [(f, owner) for (f, owner) in lst if owner != module_name]
if filtered:
_hooks[k] = filtered
else:
_hooks.pop(k, None)
async def run_hooks(name: str, require_enabled: bool = True, **kwargs) -> list[Any]:
results: list[Any] = []
for func, owner in _hooks.get(name, []):
if require_enabled and owner:
try:
from utils.modules_manager import manager
if not manager.is_enabled(owner):
continue
except Exception:
pass
try:
if inspect.iscoroutinefunction(func):
result = await func(**kwargs)
@@ -27,7 +58,5 @@ async def run_hooks(name: str, **kwargs) -> list[Any]:
if result:
results.append(result)
except Exception as e:
from logger import logger
logger.error(f"[HOOK:{name}] Ошибка в {func.__name__}: {e}")
logger.error(f"[HOOK:{name}] Ошибка в {getattr(func, '__name__', func)}: {e}")
return results
+22 -8
View File
@@ -7,6 +7,11 @@ from aiogram import Router
from logger import logger
from .modules_manager import manager
modules_hub = Router(name="modules_hub")
def load_modules_from_folder(folder: str = "modules") -> list[Router]:
routers = []
@@ -17,11 +22,18 @@ def load_modules_from_folder(folder: str = "modules") -> list[Router]:
return []
for _finder, name, _ispkg in pkgutil.iter_modules([str(base_path)]):
if not manager.should_autostart(name):
logger.info(f"[Modules] Пропуск автозапуска модуля '{name}' (отключён).")
continue
module_path = f"{folder}.{name}.router"
try:
mod = importlib.import_module(module_path)
if hasattr(mod, "router") and isinstance(mod.router, Router):
routers.append(mod.router)
router = getattr(mod, "router", None)
if isinstance(router, Router):
modules_hub.include_router(router)
manager.adopt(name, router)
routers.append(router)
logger.info(f"[Modules] Загружен модуль: {module_path}")
else:
logger.warning(f"[Modules] В модуле {module_path} не найден router")
@@ -33,12 +45,15 @@ def load_modules_from_folder(folder: str = "modules") -> list[Router]:
def load_module_webhooks(folder: str = "modules") -> list[dict]:
webhooks = []
base_path = Path(folder)
if not base_path.exists():
logger.warning(f"[Modules] Папка {folder} не найдена, пропускаем загрузку вебхуков.")
return []
for _finder, name, _ispkg in pkgutil.iter_modules([str(base_path)]):
if not manager.should_autostart(name):
logger.info(f"[Modules] Пропуск вебхуков модуля '{name}' (отключён).")
continue
module_path = f"{folder}.{name}"
try:
router_module = importlib.import_module(f"{module_path}.router")
@@ -47,22 +62,23 @@ def load_module_webhooks(folder: str = "modules") -> list[dict]:
if isinstance(webhook_data, dict) and "path" in webhook_data and "handler" in webhook_data:
webhooks.append(webhook_data)
logger.info(f"[Modules] Найден вебхук в модуле {name}: {webhook_data['path']}")
except Exception as e:
logger.error(f"[Modules] Ошибка при загрузке вебхуков из {module_path}: {e}")
return webhooks
def load_module_fast_flow_handlers(folder: str = "modules") -> dict:
handlers = {}
base_path = Path(folder)
if not base_path.exists():
logger.warning(f"[Modules] Папка {folder} не найдена, пропускаем загрузку быстрого флоу.")
return {}
for _finder, name, _ispkg in pkgutil.iter_modules([str(base_path)]):
if not manager.should_autostart(name):
logger.info(f"[Modules] Пропуск fast-flow модуля '{name}' (отключён).")
continue
module_path = f"{folder}.{name}"
try:
router_module = importlib.import_module(f"{module_path}.router")
@@ -80,8 +96,6 @@ def load_module_fast_flow_handlers(folder: str = "modules") -> dict:
logger.info(f"[Modules] Найден обработчик быстрого флоу в модуле {name}: {payment_key}")
elif fast_flow_data is None:
logger.info(f"[Modules] Быстрое флоу отключено в модуле {name}")
except Exception as e:
logger.error(f"[Modules] Ошибка при загрузке быстрого флоу из {module_path}: {e}")
return handlers
+159
View File
@@ -0,0 +1,159 @@
import importlib
import json
import os
import sys
from typing import Optional
from aiogram import Router
from hooks.hooks import unregister_module_hooks
from logger import logger
IGNORE_SUBMODULES = {"models", "schemas", "db"}
STATE_FILE = os.getenv("MODULES_STATE_FILE", "storage/modules_state.json")
class ModuleRecord:
def __init__(self, name: str, pkg: str) -> None:
self.name = name
self.pkg = pkg
self.router: Router | None = None
self.enabled: bool = False
class ModulesManager:
def __init__(self, base: str = "modules") -> None:
self.base = base
self.registry: dict[str, ModuleRecord] = {}
self.disabled: set[str] = set()
self._load_state()
def pkg(self, name: str) -> str:
return f"{self.base}.{name}"
def _load_state(self) -> None:
try:
if os.path.isfile(STATE_FILE):
with open(STATE_FILE, encoding="utf-8") as f:
data = json.load(f)
self.disabled = set(data.get("disabled", []))
else:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
self._save_state()
except Exception as e:
logger.warning(f"[Modules] Не удалось загрузить состояние: {e}")
def _save_state(self) -> None:
try:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump({"disabled": sorted(self.disabled)}, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.warning(f"[Modules] Не удалось сохранить состояние: {e}")
def adopt(self, name: str, router: Router):
rec = self.registry.get(name) or ModuleRecord(name, self.pkg(name))
rec.router = router
rec.enabled = True
self.registry[name] = rec
async def start(self, name: str) -> None:
rec = self.registry.get(name) or ModuleRecord(name, self.pkg(name))
if rec.enabled:
logger.info(f"[Modules] {name} уже активен.")
return
try:
unregister_module_hooks(name)
except Exception:
pass
self.purge_selective(rec.pkg)
mod = importlib.import_module(f"{rec.pkg}.router")
router = getattr(mod, "router", None)
if not isinstance(router, Router):
raise RuntimeError(f"[Modules] В модуле {name} не найден router")
from utils.modules_loader import modules_hub
modules_hub.include_router(router)
rec.router = router
rec.enabled = True
self.registry[name] = rec
if name in self.disabled:
self.disabled.discard(name)
self._save_state()
logger.info(f"[Modules] {name} запущен.")
async def stop(self, name: str) -> None:
rec = self.registry.get(name)
if not rec or not rec.enabled:
logger.info(f"[Modules] {name} уже остановлен или не найден.")
if name not in self.disabled:
self.disabled.add(name)
self._save_state()
return
try:
unregister_module_hooks(name)
except Exception:
pass
from utils.modules_loader import modules_hub
sub = getattr(modules_hub, "_sub_routers", None) or getattr(modules_hub, "sub_routers", None)
if sub and rec.router in sub:
sub.remove(rec.router)
rec.router = None
rec.enabled = False
if name not in self.disabled:
self.disabled.add(name)
self._save_state()
logger.info(f"[Modules] {name} остановлен.")
async def restart(self, name: str) -> None:
logger.info(f"[Modules] Перезапуск {name}...")
await self.stop(name)
await self.start(name)
def purge_selective(self, root_pkg: str) -> None:
to_del = []
for m in list(sys.modules):
if m == root_pkg or m.startswith(root_pkg + "."):
tail = m[len(root_pkg) :].lstrip(".")
top = tail.split(".", 1)[0] if tail else ""
if top and top in IGNORE_SUBMODULES:
continue
to_del.append(m)
for m in to_del:
sys.modules.pop(m, None)
importlib.invalidate_caches()
def is_enabled(self, name: str) -> bool:
rec = self.registry.get(name)
if not rec or not rec.router:
return False
try:
from utils.modules_loader import modules_hub
except Exception:
return bool(rec.enabled)
sub = getattr(modules_hub, "_sub_routers", None) or getattr(modules_hub, "sub_routers", None)
return bool(sub and rec.router in sub)
def is_disabled(self, name: str) -> bool:
return name in self.disabled
def should_autostart(self, name: str) -> bool:
return name not in self.disabled
manager = ModulesManager()