diff --git a/.gitignore b/.gitignore index 2bbdfa4f..ab7d7aca 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,5 @@ Dockerfile setup.py .ruff_cache .github/workflows/ -modules/ \ No newline at end of file +modules/ +storage/ \ No newline at end of file diff --git a/bot.py b/bot.py index ce3bf5cd..cdd2a161 100644 --- a/bot.py +++ b/bot.py @@ -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()) diff --git a/cli_launcher.py b/cli_launcher.py index 372c8b08..1be345ba 100755 --- a/cli_launcher.py +++ b/cli_launcher.py @@ -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: diff --git a/handlers/admin/__init__.py b/handlers/admin/__init__.py index a8cb4f74..3c75ba2c 100644 --- a/handlers/admin/__init__.py +++ b/handlers/admin/__init__.py @@ -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, diff --git a/handlers/admin/module/__init__.py b/handlers/admin/module/__init__.py new file mode 100644 index 00000000..905d66ba --- /dev/null +++ b/handlers/admin/module/__init__.py @@ -0,0 +1,3 @@ +__all__ = ("router",) + +from .module_handler import router diff --git a/handlers/admin/module/keyboard.py b/handlers/admin/module/keyboard.py new file mode 100644 index 00000000..d1f2e58a --- /dev/null +++ b/handlers/admin/module/keyboard.py @@ -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() diff --git a/handlers/admin/module/module_handler.py b/handlers/admin/module/module_handler.py new file mode 100644 index 00000000..e8f13846 --- /dev/null +++ b/handlers/admin/module/module_handler.py @@ -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
{lines}" + 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"🧩 Модуль: {title}\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"🧩 Модуль: {title}\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"🧩 Модуль: {title}\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 diff --git a/handlers/admin/panel/keyboard.py b/handlers/admin/panel/keyboard.py index 4472cdf2..a2072d7a 100644 --- a/handlers/admin/panel/keyboard.py +++ b/handlers/admin/panel/keyboard.py @@ -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() diff --git a/handlers/instructions/instructions.py b/handlers/instructions/instructions.py index e8717044..541b7b03 100644 --- a/handlers/instructions/instructions.py +++ b/handlers/instructions/instructions.py @@ -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}")) diff --git a/handlers/keys/key_connect.py b/handlers/keys/key_connect.py index de500f5a..ece5e4e6 100644 --- a/handlers/keys/key_connect.py +++ b/handlers/keys/key_connect.py @@ -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")) diff --git a/handlers/keys/key_mode/key_cluster_mode.py b/handlers/keys/key_mode/key_cluster_mode.py index d7620bb3..457f60c9 100644 --- a/handlers/keys/key_mode/key_cluster_mode.py +++ b/handlers/keys/key_mode/key_cluster_mode.py @@ -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 diff --git a/handlers/keys/key_mode/key_create.py b/handlers/keys/key_mode/key_create.py index e9a33b33..b451938d 100644 --- a/handlers/keys/key_mode/key_create.py +++ b/handlers/keys/key_mode/key_create.py @@ -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"] diff --git a/handlers/keys/key_renew.py b/handlers/keys/key_renew.py index ebf1cc72..31050d71 100644 --- a/handlers/keys/key_renew.py +++ b/handlers/keys/key_renew.py @@ -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"): diff --git a/handlers/keys/key_view.py b/handlers/keys/key_view.py index 139dee1f..4b991ad8 100644 --- a/handlers/keys/key_view.py +++ b/handlers/keys/key_view.py @@ -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 diff --git a/handlers/notifications/general_notifications.py b/handlers/notifications/general_notifications.py index f5c120e5..a78f9ddf 100644 --- a/handlers/notifications/general_notifications.py +++ b/handlers/notifications/general_notifications.py @@ -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: diff --git a/handlers/payments/currency_flow.py b/handlers/payments/currency_flow.py index 2e5ed019..547e76b7 100644 --- a/handlers/payments/currency_flow.py +++ b/handlers/payments/currency_flow.py @@ -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 diff --git a/handlers/payments/gift.cpython-312-x86_64-linux-gnu.so b/handlers/payments/gift.cpython-312-x86_64-linux-gnu.so index fcd1cfa4..1f2539bb 100644 Binary files a/handlers/payments/gift.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/gift.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/tribute/handlers.cpython-312-x86_64-linux-gnu.so b/handlers/payments/tribute/handlers.cpython-312-x86_64-linux-gnu.so index 86ed376b..6bdf69fa 100644 Binary files a/handlers/payments/tribute/handlers.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/tribute/handlers.cpython-312-x86_64-linux-gnu.so differ diff --git a/handlers/payments/utils.cpython-312-x86_64-linux-gnu.so b/handlers/payments/utils.cpython-312-x86_64-linux-gnu.so index 4c55047a..1df2144f 100644 Binary files a/handlers/payments/utils.cpython-312-x86_64-linux-gnu.so and b/handlers/payments/utils.cpython-312-x86_64-linux-gnu.so differ diff --git a/hooks/hooks.py b/hooks/hooks.py index 318f1af2..6668fe59 100644 --- a/hooks/hooks.py +++ b/hooks/hooks.py @@ -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 diff --git a/utils/modules_loader.py b/utils/modules_loader.py index 08055a6f..71c470f6 100644 --- a/utils/modules_loader.py +++ b/utils/modules_loader.py @@ -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 diff --git a/utils/modules_manager.py b/utils/modules_manager.py new file mode 100644 index 00000000..3b64bb1e --- /dev/null +++ b/utils/modules_manager.py @@ -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()