diff --git a/.github/hooks/pre-commit b/.github/hooks/pre-commit index 93f252c1..4194ed90 100644 --- a/.github/hooks/pre-commit +++ b/.github/hooks/pre-commit @@ -1,23 +1,17 @@ #!/bin/sh -# Сохраняем текущие изменения echo "Сохранение текущих изменений..." git stash -q --keep-index -# Запускаем форматирование кода echo "Запуск Ruff format..." ruff format . --config pyproject.toml --exclude main.py,handlers/payments -# Запускаем проверку и исправление кода echo "Запуск Ruff check с автоисправлением..." ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix -# Добавляем изменения, внесенные форматированием git add -u -# Восстанавливаем сохраненные изменения echo "Восстановление сохраненных изменений..." git stash pop -q -# Выход с кодом 0 (успешно) exit 0 \ No newline at end of file diff --git a/api/v2/routes/auth.py b/api/v2/routes/auth.py index 5d3807de..c9105120 100644 --- a/api/v2/routes/auth.py +++ b/api/v2/routes/auth.py @@ -20,7 +20,7 @@ from utils.telegram_login import verify_telegram_login router = APIRouter(prefix="/auth", tags=["Auth"]) TOKEN_TTL_HINT = "бессрочно" if API_TOKEN_TTL_DAYS is None else f"{API_TOKEN_TTL_DAYS} дн." -TELEGRAM_LOGIN_MAX_AGE = 86400 # 24 часа +TELEGRAM_LOGIN_MAX_AGE = 86400 @router.post("/register", response_model=RegisterResponse) @@ -66,7 +66,7 @@ async def login( _LOGIN_CODES: dict[str, tuple[str, float]] = {} -_LOGIN_CODE_TTL = 600.0 # 10 min +_LOGIN_CODE_TTL = 600.0 def _clean_login_codes() -> None: diff --git a/api/v2/routes/management.py b/api/v2/routes/management.py index c84f636f..5572c514 100644 --- a/api/v2/routes/management.py +++ b/api/v2/routes/management.py @@ -210,7 +210,6 @@ def _parse_date_range( return start, end except ValueError as e: raise HTTPException(status_code=400, detail=f"Неверный формат дат: {e}") - # по умолчанию — вчера end = datetime.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) start = end - timedelta(days=1) return start, end diff --git a/audit/rules.py b/audit/rules.py index ae7209af..ed8152c0 100644 --- a/audit/rules.py +++ b/audit/rules.py @@ -110,10 +110,6 @@ _CALLBACK_PREFIX: list[tuple[str, str]] = [ ("cfg_user_traffic|", "tariff_config"), ("rename_key|", "key_manage"), ("reset_hwid|", "key_manage"), - ("freeze_subscription|", "key_manage"), - ("freeze_subscription_confirm|", "key_manage"), - ("unfreeze_subscription|", "key_manage"), - ("unfreeze_subscription_confirm|", "key_manage"), ("delete_key|", "key_manage"), ("confirm_delete|", "key_manage"), ("update_subscription|", "key_manage"), @@ -243,8 +239,6 @@ _HANDLER_CONTAINS: list[tuple[str, str] | tuple[str, str, str]] = [ ("robokassa_", "pay_start"), ("rename_key", "key_manage"), ("reset_hwid", "key_manage"), - ("freeze_subscription", "key_manage"), - ("unfreeze_subscription", "key_manage"), ("delete_key", "key_manage"), ("change_location", "key_manage"), ("select_country", "key_manage"), diff --git a/core/settings/buttons_config.py b/core/settings/buttons_config.py index fe6070a6..74bfe136 100644 --- a/core/settings/buttons_config.py +++ b/core/settings/buttons_config.py @@ -9,6 +9,7 @@ from .runtime_sync import publish_runtime_config, register_runtime_config BUTTONS_CONFIG: dict[str, bool] = DEFAULT_BUTTONS_CONFIG.copy() +BUTTONS_CONFIG.pop("TOGGLE_CLIENT_BUTTON_ENABLE", None) BUTTONS_CONFIG.setdefault("ANDROID_TV_BUTTON_ENABLE", False) BUTTONS_CONFIG.setdefault("COUPON_BUTTON_ENABLE", True) register_runtime_config("BUTTONS_CONFIG", BUTTONS_CONFIG) @@ -21,6 +22,7 @@ async def load_buttons_config(session: AsyncSession) -> None: if setting is None: buttons_config = DEFAULT_BUTTONS_CONFIG.copy() + buttons_config.pop("TOGGLE_CLIENT_BUTTON_ENABLE", None) buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False) buttons_config.setdefault("COUPON_BUTTON_ENABLE", True) setting = Setting( @@ -33,6 +35,7 @@ async def load_buttons_config(session: AsyncSession) -> None: stored = setting.value or {} buttons_config = DEFAULT_BUTTONS_CONFIG.copy() buttons_config.update(stored) + buttons_config.pop("TOGGLE_CLIENT_BUTTON_ENABLE", None) buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False) buttons_config.setdefault("COUPON_BUTTON_ENABLE", True) setting.value = buttons_config @@ -61,6 +64,7 @@ async def update_buttons_config(session: AsyncSession, new_values: dict[str, boo buttons_config = DEFAULT_BUTTONS_CONFIG.copy() buttons_config.update(new_values) + buttons_config.pop("TOGGLE_CLIENT_BUTTON_ENABLE", None) buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False) buttons_config.setdefault("COUPON_BUTTON_ENABLE", True) diff --git a/database/keys.py b/database/keys.py index 889870d1..ac32f31a 100644 --- a/database/keys.py +++ b/database/keys.py @@ -12,7 +12,7 @@ from core.cache_config import ( KEYS_LIST_CACHE_TTL_SEC, ) from core.redis_cache import cache_delete, cache_get, cache_key, cache_set -from database.models import Key, User +from database.models import Key, Tariff, User from database.users import invalidate_profile_cache, invalidate_user_snapshot from logger import logger @@ -166,6 +166,51 @@ async def get_key_by_server(session: AsyncSession, tg_id: int, client_id: str): return result.scalar_one_or_none() +async def get_key_by_email(session: AsyncSession, email: str, tg_id: int | None = None) -> Key | None: + stmt = select(Key).where(Key.email == email) + if tg_id is not None: + stmt = stmt.where(Key.tg_id == tg_id) + result = await session.execute(stmt.limit(1)) + return result.scalar_one_or_none() + + +async def get_key_by_client_id(session: AsyncSession, client_id: str, tg_id: int | None = None) -> Key | None: + stmt = select(Key).where(Key.client_id == client_id) + if tg_id is not None: + stmt = stmt.where(Key.tg_id == tg_id) + result = await session.execute(stmt.limit(1)) + return result.scalar_one_or_none() + + +async def get_key_expiry_presets(session: AsyncSession, email: str) -> tuple[str | None, list[int]]: + key_obj = await get_key_by_email(session, email) + if not key_obj: + return None, [] + + if not key_obj.tariff_id: + return key_obj.client_id, [] + + tariff = await session.execute(select(Tariff.group_code).where(Tariff.id == key_obj.tariff_id)) + group_code = tariff.scalar_one_or_none() + if not group_code: + return key_obj.client_id, [] + + result = await session.execute( + select(Tariff.duration_days) + .where(Tariff.group_code == group_code, Tariff.is_active.is_(True)) + .order_by(Tariff.duration_days) + ) + unique_durations: list[int] = [] + seen: set[int] = set() + for (days,) in result.all(): + if days is None or days < 1 or days in seen: + continue + seen.add(int(days)) + unique_durations.append(int(days)) + + return key_obj.client_id, unique_durations + + async def get_key_details(session: AsyncSession, email: str) -> dict | None: """Возвращает подробную информацию о ключе по email. Горячие данные кэшируются в Redis.""" ckey = cache_key("key_details", email) @@ -347,6 +392,24 @@ async def update_key_link(session: AsyncSession, email: str, link: str) -> bool: return ok +async def update_key_subscription_links(session: AsyncSession, email: str, link: str) -> bool: + stmt = ( + update(Key) + .where(Key.email == email) + .values( + key=link, + remnawave_link=link, + ) + .returning(Key.client_id) + ) + res = await session.execute(stmt) + await session.commit() + ok = res.scalar_one_or_none() is not None + if ok: + await invalidate_key_details(email) + return ok + + async def save_key_config_with_mode( session: AsyncSession, email: str, @@ -381,6 +444,76 @@ async def save_key_config_with_mode( await invalidate_key_details(email) +async def reset_key_tariff_state(session: AsyncSession, tg_id: int, email: str, tariff_id: int) -> None: + await session.execute( + update(Key) + .where(Key.tg_id == tg_id, Key.email == email) + .values( + tariff_id=tariff_id, + selected_device_limit=None, + current_device_limit=None, + selected_traffic_limit=None, + current_traffic_limit=None, + selected_price_rub=None, + ) + ) + await session.commit() + await invalidate_keys_list(tg_id) + await invalidate_key_details(email) + + +async def save_key_tariff_selection( + session: AsyncSession, + tg_id: int, + email: str, + tariff_id: int, + selected_devices: int | None, + selected_traffic_gb: int | None, +) -> None: + selected_devices_val = int(selected_devices) if selected_devices is not None else None + selected_traffic_val = int(selected_traffic_gb) if selected_traffic_gb is not None and int(selected_traffic_gb) > 0 else None + + await session.execute( + update(Key) + .where(Key.tg_id == tg_id, Key.email == email) + .values( + tariff_id=tariff_id, + selected_device_limit=selected_devices_val, + current_device_limit=selected_devices_val, + selected_traffic_limit=selected_traffic_val, + current_traffic_limit=selected_traffic_val, + selected_price_rub=None, + ) + ) + await session.commit() + await invalidate_keys_list(tg_id) + await invalidate_key_details(email) + + +async def save_admin_key_config( + session: AsyncSession, + email: str, + base_devices: int, + total_devices: int, + base_traffic: int | None, + total_traffic: int | None, + selected_price: int | None, +) -> None: + await session.execute( + update(Key) + .where(Key.email == email) + .values( + selected_device_limit=base_devices, + current_device_limit=total_devices, + selected_traffic_limit=base_traffic, + current_traffic_limit=total_traffic, + selected_price_rub=selected_price, + ) + ) + await session.commit() + await invalidate_key_details(email) + + async def reset_key_current_limits_to_selected(session: AsyncSession, client_id: str): """Сбрасывает текущие лимиты к выбранным для ключа.""" await session.execute( diff --git a/database/referrals.py b/database/referrals.py index 730e85ef..10eb5466 100644 --- a/database/referrals.py +++ b/database/referrals.py @@ -162,7 +162,7 @@ async def get_total_referral_bonus(session: AsyncSession, referrer_tg_id: int, m ) result = await session.execute( - text(bonus_query), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text + text(bonus_query), {"tg_id": referrer_tg_id, "max_levels": max_levels}, ) total_bonus_raw = result.scalar() @@ -193,7 +193,7 @@ async def get_referrals_by_level(session: AsyncSession, referrer_tg_id: int, max ORDER BY level """ result = await session.execute( - text(query), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text + text(query), {"referrer_tg_id": referrer_tg_id, "max_levels": max_levels}, ) return { diff --git a/database/servers.py b/database/servers.py index b3d4449f..99eddad1 100644 --- a/database/servers.py +++ b/database/servers.py @@ -124,6 +124,12 @@ async def get_clusters(session: AsyncSession) -> list[str]: return [r[0] for r in result.all()] +async def get_server_names(session: AsyncSession) -> list[str]: + stmt = select(Server.server_name).distinct().order_by(Server.server_name) + result = await session.execute(stmt) + return [r[0] for r in result.all()] + + async def check_unique_server_name(session: AsyncSession, server_name: str, cluster_name: str | None = None) -> bool: stmt = select(Server).where(Server.server_name == server_name) if cluster_name: diff --git a/database/tariffs.py b/database/tariffs.py index f06f43a4..a17f722a 100644 --- a/database/tariffs.py +++ b/database/tariffs.py @@ -147,6 +147,18 @@ async def get_tariff_by_id(session: AsyncSession, tariff_id: int): return None +async def get_tariff_group_codes(session: AsyncSession) -> list[str]: + result = await session.execute(select(Tariff.group_code).distinct().order_by(Tariff.group_code)) + return [row[0] for row in result.fetchall() if row[0]] + + +async def get_active_tariffs_by_group_code(session: AsyncSession, group_code: str) -> list[Tariff]: + result = await session.execute( + select(Tariff).where(Tariff.group_code == group_code, Tariff.is_active.is_(True)).order_by(Tariff.id) + ) + return result.scalars().all() + + async def get_tariffs_for_cluster(session: AsyncSession, cluster_name: str): key = cache_key("tariffs_cluster", cluster_name) cached = await cache_get(key) diff --git a/handlers/admin/clusters/__init__.py b/handlers/admin/clusters/__init__.py index 96d6867a..bc48e482 100644 --- a/handlers/admin/clusters/__init__.py +++ b/handlers/admin/clusters/__init__.py @@ -1,5 +1,12 @@ -from . import cluster_manage, cluster_sync, cluster_tariffs, cluster_transfers, cluster_wizard # noqa +from . import cluster_manage, cluster_sync, cluster_tariffs, cluster_transfers, cluster_wizard from .base import AdminClusterStates, router - -__all__ = ["router", "AdminClusterStates"] +__all__ = [ + "router", + "AdminClusterStates", + "cluster_manage", + "cluster_sync", + "cluster_tariffs", + "cluster_transfers", + "cluster_wizard", +] diff --git a/handlers/admin/management/__init__.py b/handlers/admin/management/__init__.py index 407200c6..0e9625e0 100644 --- a/handlers/admin/management/__init__.py +++ b/handlers/admin/management/__init__.py @@ -1,14 +1,16 @@ from aiogram import Router +from . import admins, database, domain, file_upload, import_3xui, import_remnawave, maintenance router = Router() -from . import ( - admins, # noqa: F401 - database, # noqa: F401 - domain, # noqa: F401 - file_upload, # noqa: F401 - import_3xui, # noqa: F401 - import_remnawave, # noqa: F401 - maintenance, # noqa: F401 +__all__ = ( + "router", + "admins", + "database", + "domain", + "file_upload", + "import_3xui", + "import_remnawave", + "maintenance", ) diff --git a/handlers/admin/servers/__init__.py b/handlers/admin/servers/__init__.py index 75d9cd89..0e443e28 100644 --- a/handlers/admin/servers/__init__.py +++ b/handlers/admin/servers/__init__.py @@ -1,5 +1,8 @@ -from . import ( - server_manage, # noqa: F401 - server_wizard, # noqa: F401 -) +from . import server_manage, server_wizard from .server_states import router + +__all__ = ( + "router", + "server_manage", + "server_wizard", +) diff --git a/handlers/admin/settings/settings_config.py b/handlers/admin/settings/settings_config.py index 7ad5ca69..b0c1c937 100644 --- a/handlers/admin/settings/settings_config.py +++ b/handlers/admin/settings/settings_config.py @@ -8,7 +8,6 @@ BUTTON_TITLES: Final[dict[str, str]] = { "REFERRAL_QR_BUTTON_ENABLE": "QR реф.меню", "DELETE_KEY_BUTTON_ENABLE": "Удалить подп-ку", "INSTRUCTIONS_BUTTON_ENABLE": "Инструкции", - "TOGGLE_CLIENT_BUTTON_ENABLE": "Заморозка подписки", "GIFT_BUTTON_ENABLE": "Подарки", "REFERRAL_BUTTON_ENABLE": "Реф.система", "TOP_REFERRAL_BUTTON_ENABLE": "Топ-5 рефералов", diff --git a/handlers/admin/tariffs/__init__.py b/handlers/admin/tariffs/__init__.py index 45c370d5..605ebe6f 100644 --- a/handlers/admin/tariffs/__init__.py +++ b/handlers/admin/tariffs/__init__.py @@ -1,11 +1,13 @@ from aiogram import Router +from . import tariff_configurator, tariff_manage, tariff_sorting, tariff_subgroups router = Router() -from . import ( # noqa: F401 - tariff_configurator, - tariff_manage, - tariff_sorting, - tariff_subgroups, +__all__ = ( + "router", + "tariff_configurator", + "tariff_manage", + "tariff_sorting", + "tariff_subgroups", ) diff --git a/handlers/admin/tariffs/tariff_configurator/__init__.py b/handlers/admin/tariffs/tariff_configurator/__init__.py index 66f7f8e0..ea7a8482 100644 --- a/handlers/admin/tariffs/tariff_configurator/__init__.py +++ b/handlers/admin/tariffs/tariff_configurator/__init__.py @@ -1,6 +1,8 @@ -from . import ( # noqa: F401 - common, - device_pricing, - options, - traffic_pricing, +from . import common, device_pricing, options, traffic_pricing + +__all__ = ( + "common", + "device_pricing", + "options", + "traffic_pricing", ) diff --git a/handlers/admin/users/keyboard.py b/handlers/admin/users/keyboard.py index 3ee5b5c5..143f3098 100644 --- a/handlers/admin/users/keyboard.py +++ b/handlers/admin/users/keyboard.py @@ -3,19 +3,18 @@ from datetime import datetime, timezone from aiogram.filters.callback_data import CallbackData from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from handlers.buttons import BACK from config import HWID_RESET_BUTTON -from database import get_clusters -from database.models import Key, Tariff +from database import get_clusters, get_key_expiry_presets from handlers.buttons import BACK, FREEZE, UNFREEZE from handlers.utils import format_days from hooks.hook_buttons import insert_hook_buttons from hooks.hooks import run_hooks from ..panel.keyboard import build_admin_back_btn +from .utils import build_admin_key_ref class AdminUserEditorCallback(CallbackData, prefix="admin_users"): @@ -51,12 +50,13 @@ async def build_user_edit_kb( for record in key_records: email = record.email + key_ref = build_admin_key_ref(record.client_id, email) expiry = datetime.fromtimestamp(record.expiry_time / 1000, tz=timezone.utc) days = (expiry - current_time).days builder.row( InlineKeyboardButton( text=f"🔑 {email} ({'<1' if days < 1 else days} дн.)", - callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=str(email)).pack(), + callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=key_ref).pack(), ) ) @@ -191,74 +191,63 @@ async def build_users_balance_kb( return builder.as_markup() -def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup: +def build_users_key_show_kb(tg_id: int, key_ref: str) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button( text=BACK, - callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email, edit=True).pack(), + callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=key_ref, edit=True).pack(), ) return builder.as_markup() -async def build_users_key_expiry_kb(session: AsyncSession, tg_id: int, email: str) -> InlineKeyboardMarkup: +async def build_users_key_expiry_kb( + session: AsyncSession, + tg_id: int, + email: str, + key_ref: str | None = None, +) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() + client_id, preset_durations = await get_key_expiry_presets(session, email) + resolved_key_ref = key_ref or build_admin_key_ref(client_id, email) - result = await session.execute(select(Key.server_id, Key.tariff_id).where(Key.email == email)) - row = result.first() - _server_id, tariff_id = row if row else (None, None) - - if tariff_id: - result = await session.execute(select(Tariff.group_code).where(Tariff.id == tariff_id)) - row = result.first() - if row and row[0]: - group_code = row[0] - result = await session.execute( - select(Tariff).where(Tariff.group_code == group_code, Tariff.is_active.is_(True)) - ) - tariffs = result.scalars().all() - unique_durations = set() - for tariff in tariffs: - days = tariff.duration_days - if days < 1 or days in unique_durations: - continue - unique_durations.add(days) - label = format_days(days) - builder.row( - InlineKeyboardButton( - text=f"+ {label}", - callback_data=AdminUserKeyEditorCallback( - action="add", tg_id=tg_id, data=email, month=days - ).pack(), - ), - InlineKeyboardButton( - text=f"- {label}", - callback_data=AdminUserKeyEditorCallback( - action="add", tg_id=tg_id, data=email, month=-days - ).pack(), - ), - ) + for days in preset_durations: + label = format_days(days) + builder.row( + InlineKeyboardButton( + text=f"+ {label}", + callback_data=AdminUserKeyEditorCallback( + action="add", tg_id=tg_id, data=resolved_key_ref, month=days + ).pack(), + ), + InlineKeyboardButton( + text=f"- {label}", + callback_data=AdminUserKeyEditorCallback( + action="add", tg_id=tg_id, data=resolved_key_ref, month=-days + ).pack(), + ), + ) builder.row( InlineKeyboardButton( text="⏳ Добавить дни", - callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email).pack(), + callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=resolved_key_ref).pack(), ), InlineKeyboardButton( text="⏳ Вычесть дни", - callback_data=AdminUserKeyEditorCallback(action="take", tg_id=tg_id, data=email).pack(), + callback_data=AdminUserKeyEditorCallback(action="take", tg_id=tg_id, data=resolved_key_ref).pack(), ), ) builder.row( InlineKeyboardButton( text="⏳ Установить дату истечения", - callback_data=AdminUserKeyEditorCallback(action="set", tg_id=tg_id, data=email).pack(), + callback_data=AdminUserKeyEditorCallback(action="set", tg_id=tg_id, data=resolved_key_ref).pack(), ) ) builder.row( InlineKeyboardButton( text=BACK, - callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(), + callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=resolved_key_ref).pack(), ) ) @@ -276,18 +265,24 @@ def build_user_delete_kb(tg_id: int): return builder.as_markup() -def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup: +def build_user_key_kb(tg_id: int, key_ref: str) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button( text=BACK, - callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(), + callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=key_ref).pack(), ) builder.adjust(1) return builder.as_markup() -def build_key_edit_kb(key_details: dict, email: str, is_configurable: bool = False) -> InlineKeyboardMarkup: +def build_key_edit_kb( + key_details: dict, + email: str, + is_configurable: bool = False, + key_ref: str | None = None, +) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() + resolved_key_ref = key_ref or build_admin_key_ref(key_details.get("client_id"), email) is_frozen = ( key_details.get("is_frozen") if isinstance(key_details, dict) else getattr(key_details, "is_frozen", False) @@ -296,38 +291,44 @@ def build_key_edit_kb(key_details: dict, email: str, is_configurable: bool = Fal builder.button( text="⏳ Время истечения", callback_data=AdminUserEditorCallback( - action="users_expiry_edit", data=email, tg_id=key_details["tg_id"] + action="users_expiry_edit", data=resolved_key_ref, tg_id=key_details["tg_id"] ).pack(), ) builder.button( text="🔄 Перевыпуск подписки", callback_data=AdminUserEditorCallback( - action="users_reissue_menu", data=email, tg_id=key_details["tg_id"] + action="users_reissue_menu", data=resolved_key_ref, tg_id=key_details["tg_id"] ).pack(), ) builder.button( text="📦 Тариф", - callback_data=AdminUserEditorCallback(action="users_renew", data=email, tg_id=key_details["tg_id"]).pack(), + callback_data=AdminUserEditorCallback( + action="users_renew", data=resolved_key_ref, tg_id=key_details["tg_id"] + ).pack(), ) if is_configurable: builder.button( text="📱 Конфигурация", callback_data=AdminUserEditorCallback( - action="users_edit_config", data=email, tg_id=key_details["tg_id"] + action="users_edit_config", data=resolved_key_ref, tg_id=key_details["tg_id"] ).pack(), ) builder.button( text="❌ Удалить", - callback_data=AdminUserEditorCallback(action="users_delete_key", data=email, tg_id=key_details["tg_id"]).pack(), + callback_data=AdminUserEditorCallback( + action="users_delete_key", data=resolved_key_ref, tg_id=key_details["tg_id"] + ).pack(), ) builder.button( text="📊 Трафик", - callback_data=AdminUserEditorCallback(action="users_traffic", data=email, tg_id=key_details["tg_id"]).pack(), + callback_data=AdminUserEditorCallback( + action="users_traffic", data=resolved_key_ref, tg_id=key_details["tg_id"] + ).pack(), ) builder.button( text="♻️ Сбросить трафик", callback_data=AdminUserEditorCallback( - action="users_reset_traffic", data=email, tg_id=key_details["tg_id"] + action="users_reset_traffic", data=resolved_key_ref, tg_id=key_details["tg_id"] ).pack(), ) @@ -335,20 +336,22 @@ def build_key_edit_kb(key_details: dict, email: str, is_configurable: bool = Fal builder.button( text=UNFREEZE, callback_data=AdminUserEditorCallback( - action="users_unfreeze", data=email, tg_id=key_details["tg_id"] + action="users_unfreeze", data=resolved_key_ref, tg_id=key_details["tg_id"] ).pack(), ) else: builder.button( text=FREEZE, - callback_data=AdminUserEditorCallback(action="users_freeze", data=email, tg_id=key_details["tg_id"]).pack(), + callback_data=AdminUserEditorCallback( + action="users_freeze", data=resolved_key_ref, tg_id=key_details["tg_id"] + ).pack(), ) if HWID_RESET_BUTTON: builder.button( text="💻 HWID", callback_data=AdminUserEditorCallback( - action="users_hwid_menu", data=email, tg_id=key_details["tg_id"] + action="users_hwid_menu", data=resolved_key_ref, tg_id=key_details["tg_id"] ).pack(), ) @@ -357,33 +360,33 @@ def build_key_edit_kb(key_details: dict, email: str, is_configurable: bool = Fal return builder.as_markup() -def build_reissue_menu_kb(email: str, tg_id: int) -> InlineKeyboardMarkup: +def build_reissue_menu_kb(key_ref: str, tg_id: int) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button( text="📦 Полный перевыпуск", - callback_data=AdminUserEditorCallback(action="users_update_key", data=email, tg_id=tg_id).pack(), + callback_data=AdminUserEditorCallback(action="users_update_key", data=key_ref, tg_id=tg_id).pack(), ) builder.button( text="🔗 Сменить ссылку", - callback_data=AdminUserEditorCallback(action="users_recreate_key", data=email, tg_id=tg_id).pack(), + callback_data=AdminUserEditorCallback(action="users_recreate_key", data=key_ref, tg_id=tg_id).pack(), ) builder.button( text=BACK, - callback_data=AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id).pack(), + callback_data=AdminUserEditorCallback(action="users_key_edit", data=key_ref, tg_id=tg_id).pack(), ) builder.adjust(1) return builder.as_markup() -def build_hwid_menu_kb(email: str, tg_id: int) -> InlineKeyboardMarkup: +def build_hwid_menu_kb(key_ref: str, tg_id: int) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button( text="♻️ Сбросить HWID", - callback_data=AdminUserEditorCallback(action="users_hwid_reset", data=email, tg_id=tg_id).pack(), + callback_data=AdminUserEditorCallback(action="users_hwid_reset", data=key_ref, tg_id=tg_id).pack(), ) builder.button( text=BACK, - callback_data=AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id).pack(), + callback_data=AdminUserEditorCallback(action="users_key_edit", data=key_ref, tg_id=tg_id).pack(), ) builder.adjust(1) return builder.as_markup() @@ -427,15 +430,15 @@ def build_editor_btn(text: str, tg_id: int, edit: bool = False) -> InlineKeyboar ) -async def build_cluster_selection_kb(session, tg_id: int, email: str, action: str) -> InlineKeyboardMarkup: +async def build_cluster_selection_kb(session, tg_id: int, key_ref: str, action: str) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() clusters = await get_clusters(session) for cluster_id in clusters: - builder.button(text=cluster_id, callback_data=f"{action}|{tg_id}|{email}|{cluster_id}") + builder.button(text=cluster_id, callback_data=f"{action}|{tg_id}|{key_ref}|{cluster_id}") builder.button( - text=BACK, callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack() + text=BACK, callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=key_ref).pack() ) builder.adjust(1) return builder.as_markup() diff --git a/handlers/admin/users/users_audit.py b/handlers/admin/users/users_audit.py index 78715503..2ab5c796 100644 --- a/handlers/admin/users/users_audit.py +++ b/handlers/admin/users/users_audit.py @@ -205,7 +205,6 @@ def _resolve_event_types(category_filter: str) -> list[str] | None: return sorted(category_events) -# Порядок и подписи блоков при показе «все» категории CATEGORY_BLOCK_ORDER = ("auth", "subscriptions", "payments", "marketing", "other") CATEGORY_BLOCK_LABELS = { "auth": "Авторизация", @@ -258,7 +257,6 @@ def _event_label(event_type: str) -> str: return EVENT_TYPE_LABELS.get(event_type, event_type) -# Отступ для строки события под статусом (чтобы не слипалось) _FLOW_INDENT = " " @@ -266,7 +264,6 @@ def _humanize_path(path: str) -> str: """Сокращает типичные callback для админки до читаемого вида.""" if not path or "callback:" not in path: return path - # callback:admin_users:users_audit:476217106:telegram | all | 0:0 if "users_audit:" in path: rest = path.split("users_audit:", 1)[-1].strip() parts = [p.strip() for p in rest.split("|")[:2] if p.strip()] @@ -335,7 +332,6 @@ def _format_event_status(event) -> str: return f"{created_at} {result_text}" -# Разделитель между событиями — сразу видно границу "что где" _FLOW_SEP = "—" def _render_events_as_flow(events: list) -> list[str]: @@ -421,7 +417,7 @@ async def _render_user_audit( lines.append("\nСобытий пока нет.") else: lines.append("") - rev = list(reversed(events)) # хронология сверху вниз + rev = list(reversed(events)) if full_flow: by_cat: dict[str, list] = {} for e in rev: diff --git a/handlers/admin/users/users_hwid.py b/handlers/admin/users/users_hwid.py index 5dac73e6..13e20a53 100644 --- a/handlers/admin/users/users_hwid.py +++ b/handlers/admin/users/users_hwid.py @@ -7,10 +7,10 @@ from panels.remnawave_runtime import ( resolve_remnawave_api_url, with_remnawave_api, ) -from database import get_client_id_by_email from filters.admin import IsAdminFilter from .keyboard import AdminUserEditorCallback, build_editor_kb, build_hwid_menu_kb +from .utils import resolve_admin_key router = Router() @@ -25,13 +25,14 @@ async def handle_hwid_menu( callback_data: AdminUserEditorCallback, session: AsyncSession, ): - email = callback_data.data + key_ref = str(callback_data.data) tg_id = callback_data.tg_id - client_id = await get_client_id_by_email(session, email) - if not client_id: - await callback_query.message.edit_text("🚫 Не удалось найти client_id по email.") + key_obj = await resolve_admin_key(session, tg_id, key_ref) + if not key_obj: + await callback_query.message.edit_text("🚫 Не удалось найти ключ.", reply_markup=build_editor_kb(tg_id)) return + client_id = key_obj.client_id remna_api_url = await resolve_remnawave_api_url(session, "", fallback_any=True) if not remna_api_url: @@ -109,7 +110,7 @@ async def handle_hwid_menu( f"└ 🔄 Обновлено: {updated}\n\n" ) - await callback_query.message.edit_text(text, reply_markup=build_hwid_menu_kb(email, tg_id)) + await callback_query.message.edit_text(text, reply_markup=build_hwid_menu_kb(key_ref, tg_id)) @router.callback_query( @@ -121,13 +122,14 @@ async def handle_hwid_reset( callback_data: AdminUserEditorCallback, session: AsyncSession, ): - email = callback_data.data + key_ref = str(callback_data.data) tg_id = callback_data.tg_id - client_id = await get_client_id_by_email(session, email) - if not client_id: - await callback_query.message.edit_text("🚫 Не удалось найти client_id по email.") + key_obj = await resolve_admin_key(session, tg_id, key_ref) + if not key_obj: + await callback_query.message.edit_text("🚫 Не удалось найти ключ.", reply_markup=build_editor_kb(tg_id)) return + client_id = key_obj.client_id remna_api_url = await resolve_remnawave_api_url(session, "", fallback_any=True) if not remna_api_url: diff --git a/handlers/admin/users/users_keys.py b/handlers/admin/users/users_keys.py index 82d5b562..f0571725 100644 --- a/handlers/admin/users/users_keys.py +++ b/handlers/admin/users/users_keys.py @@ -8,26 +8,33 @@ from handlers.buttons import BACK import pytz from aiogram import F, Router, types +from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InlineKeyboardButton, Message from aiogram.utils.keyboard import InlineKeyboardBuilder -from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, REMNAWAVE_TOKEN_LOGIN_ENABLED, USE_COUNTRY_SELECTION from core.bootstrap import MODES_CONFIG from database import ( + check_server_name_by_cluster, delete_key, delete_user_data, + get_active_tariffs_by_group_code, + get_key_by_email, get_key_details, + get_keys, + get_server_names, get_servers, get_tariff_by_id, get_tariffs_for_cluster, mark_key_as_frozen, mark_key_as_unfrozen, + save_admin_key_config, + update_key_subscription_links, update_key_expiry, ) -from database.models import Key, Server, Tariff +from database.models import Key from filters.admin import IsAdminFilter from middlewares.session import release_session_early from handlers.keys.operations import ( @@ -58,6 +65,7 @@ from .keyboard import ( build_users_key_expiry_kb, build_users_key_show_kb, ) +from .utils import resolve_admin_key from .users_states import RenewTariffState, UserEditorState @@ -66,6 +74,14 @@ MOSCOW_TZ = pytz.timezone("Europe/Moscow") router = Router() +async def _resolve_callback_key( + session: AsyncSession, + tg_id: int, + key_ref: str | int | None, +) -> Key | None: + return await resolve_admin_key(session, tg_id, key_ref) + + @router.callback_query( AdminUserEditorCallback.filter(F.action == "users_key_edit"), IsAdminFilter(), @@ -76,9 +92,8 @@ async def handle_key_edit( session: AsyncSession, update: bool = False, ): - email = callback_data.data - result = await session.execute(select(Key).where(Key.email == email)) - key_obj: Key | None = result.scalar_one_or_none() + key_ref = callback_data.data + key_obj = await _resolve_callback_key(session, callback_data.tg_id, key_ref) if not key_obj: await callback_query.message.edit_text( @@ -87,6 +102,10 @@ async def handle_key_edit( ) return + email = key_obj.email + key_details = await get_key_details(session, email) + is_frozen = bool(key_details.get("is_frozen")) if key_details else bool(getattr(key_obj, "is_frozen", False)) + key_value = key_obj.key or key_obj.remnawave_link or "—" alias_part = f" ({key_obj.alias})" if key_obj.alias else "" @@ -96,10 +115,26 @@ async def handle_key_edit( else: created_at = "—" - if key_obj.expiry_time: + if is_frozen: + frozen_left_ms = int((key_details or {}).get("expiry_time") or 0) + total_minutes = max(frozen_left_ms // 60000, 0) + days, rem_minutes = divmod(total_minutes, 24 * 60) + hours, minutes = divmod(rem_minutes, 60) + frozen_parts: list[str] = [] + if days: + frozen_parts.append(f"{days} дн.") + if hours: + frozen_parts.append(f"{hours} ч.") + if minutes or not frozen_parts: + frozen_parts.append(f"{minutes} мин.") + expiry_label = "⏳ Остаток:" + expiry_date = " ".join(frozen_parts) + elif key_obj.expiry_time: expiry_dt = datetime.fromtimestamp(int(key_obj.expiry_time) / 1000, tz=MOSCOW_TZ) + expiry_label = "⏰ Истекает:" expiry_date = expiry_dt.strftime("%d %B %Y года %H:%M") else: + expiry_label = "⏰ Истекает:" expiry_date = "—" tariff_name = "—" @@ -109,24 +144,14 @@ async def handle_key_edit( base_traffic = None is_configurable = False if key_obj.tariff_id: - result = await session.execute( - select( - Tariff.name, - Tariff.subgroup_title, - Tariff.group_code, - Tariff.device_limit, - Tariff.traffic_limit, - Tariff.configurable, - ).where(Tariff.id == key_obj.tariff_id) - ) - row = result.first() - if row: - tariff_name = row[0] - subgroup_title = row[1] or "—" - group_code = row[2] or "—" - base_devices = row[3] - base_traffic = row[4] - is_configurable = bool(row[5]) + tariff = await get_tariff_by_id(session, key_obj.tariff_id) + if tariff: + tariff_name = tariff.get("name", "—") + subgroup_title = tariff.get("subgroup_title") or "—" + group_code = tariff.get("group_code") or "—" + base_devices = tariff.get("device_limit") + base_traffic = tariff.get("traffic_limit") + is_configurable = bool(tariff.get("configurable")) devices_line = "" traffic_line = "" @@ -156,7 +181,8 @@ async def handle_key_edit( "
" f"🔗 Ключ{alias_part}: {key_value}\n" f"📆 Создан: {created_at} (МСК)\n" - f"⏰ Истекает: {expiry_date} (МСК)\n" + f"{'⛔ Статус: отключена\n' if is_frozen else ''}" + f"{expiry_label} {expiry_date}{' (МСК)' if not is_frozen and expiry_date != '—' else ''}\n" f"🌐 Кластер: {key_obj.server_id or '—'}\n" f"🆔 ID клиента: {key_obj.tg_id or '—'}\n" f"🏷️ Тарифная группа: {group_code}\n" @@ -168,7 +194,9 @@ async def handle_key_edit( ) if not update or not getattr(callback_data, "edit", False): - kb_markup = build_key_edit_kb(key_obj.__dict__, email, is_configurable=is_configurable) + kb_key_details = dict(key_obj.__dict__) + kb_key_details["is_frozen"] = is_frozen + kb_markup = build_key_edit_kb(kb_key_details, email, is_configurable=is_configurable, key_ref=str(key_ref)) kb_builder = InlineKeyboardBuilder.from_markup(kb_markup) hook_buttons = await process_admin_key_edit_menu( email=email, @@ -177,15 +205,28 @@ async def handle_key_edit( tg_id=key_obj.tg_id, ) kb_builder = insert_hook_buttons(kb_builder, hook_buttons) - await callback_query.message.edit_text( - text=text, - reply_markup=kb_builder.as_markup(), - ) + try: + await callback_query.message.edit_text( + text=text, + reply_markup=kb_builder.as_markup(), + ) + except TelegramBadRequest as e: + if "message is not modified" not in str(e): + raise else: - await callback_query.message.edit_text( - text=text, - reply_markup=await build_users_key_expiry_kb(session, callback_data.tg_id, email), - ) + try: + await callback_query.message.edit_text( + text=text, + reply_markup=await build_users_key_expiry_kb( + session, + callback_data.tg_id, + email, + key_ref=str(key_ref), + ), + ) + except TelegramBadRequest as e: + if "message is not modified" not in str(e): + raise @router.callback_query( @@ -198,9 +239,19 @@ async def handle_change_expiry( session: AsyncSession, ): tg_id = callback_data.tg_id - email = callback_data.data + key_ref = str(callback_data.data) + key_obj = await _resolve_callback_key(session, tg_id, key_ref) + if not key_obj: + await callback_query.message.edit_text( + text="🚫 Информация о ключе не найдена.", + reply_markup=build_editor_kb(tg_id), + ) + return + email = key_obj.email - await callback_query.message.edit_reply_markup(reply_markup=await build_users_key_expiry_kb(session, tg_id, email)) + await callback_query.message.edit_reply_markup( + reply_markup=await build_users_key_expiry_kb(session, tg_id, email, key_ref=key_ref) + ) @router.callback_query( @@ -214,7 +265,15 @@ async def handle_expiry_add( session: AsyncSession, ): tg_id = callback_data.tg_id - email = callback_data.data + key_ref = str(callback_data.data) + key_obj = await _resolve_callback_key(session, tg_id, key_ref) + if not key_obj: + await callback_query.message.edit_text( + text="🚫 Информация о ключе не найдена.", + reply_markup=build_editor_kb(tg_id), + ) + return + email = key_obj.email days = callback_data.month key_details = await get_key_details(session, email) @@ -231,12 +290,12 @@ async def handle_expiry_add( await handle_key_edit(callback_query, callback_data, session, True) return - await state.update_data(tg_id=tg_id, email=email, op_type="add") + await state.update_data(tg_id=tg_id, email=email, key_ref=key_ref, op_type="add") await state.set_state(UserEditorState.waiting_for_expiry_time) await callback_query.message.edit_text( text="✍️ Введите количество дней, которое хотите добавить к времени действия ключа:", - reply_markup=build_users_key_show_kb(tg_id, email), + reply_markup=build_users_key_show_kb(tg_id, key_ref), ) @@ -248,16 +307,25 @@ async def handle_expiry_take( callback_query: CallbackQuery, callback_data: AdminUserKeyEditorCallback, state: FSMContext, + session: AsyncSession, ): tg_id = callback_data.tg_id - email = callback_data.data + key_ref = str(callback_data.data) + key_obj = await _resolve_callback_key(session, tg_id, key_ref) + if not key_obj: + await callback_query.message.edit_text( + text="🚫 Информация о ключе не найдена.", + reply_markup=build_editor_kb(tg_id), + ) + return + email = key_obj.email - await state.update_data(tg_id=tg_id, email=email, op_type="take") + await state.update_data(tg_id=tg_id, email=email, key_ref=key_ref, op_type="take") await state.set_state(UserEditorState.waiting_for_expiry_time) await callback_query.message.edit_text( text="✍️ Введите количество дней, которое хотите вычесть из времени действия ключа:", - reply_markup=build_users_key_show_kb(tg_id, email), + reply_markup=build_users_key_show_kb(tg_id, key_ref), ) @@ -272,7 +340,15 @@ async def handle_expiry_set( session: AsyncSession, ): tg_id = callback_data.tg_id - email = callback_data.data + key_ref = str(callback_data.data) + key_obj = await _resolve_callback_key(session, tg_id, key_ref) + if not key_obj: + await callback_query.message.edit_text( + text="🚫 Информация о ключе не найдена.", + reply_markup=build_editor_kb(tg_id), + ) + return + email = key_obj.email key_details = await get_key_details(session, email) @@ -283,7 +359,7 @@ async def handle_expiry_set( ) return - await state.update_data(tg_id=tg_id, email=email, op_type="set") + await state.update_data(tg_id=tg_id, email=email, key_ref=key_ref, op_type="set") await state.set_state(UserEditorState.waiting_for_expiry_time) text = ( @@ -294,7 +370,7 @@ async def handle_expiry_set( await callback_query.message.edit_text( text=text, - reply_markup=build_users_key_show_kb(tg_id, email), + reply_markup=build_users_key_show_kb(tg_id, key_ref), ) @@ -303,12 +379,13 @@ async def handle_expiry_time_input(message: Message, state: FSMContext, session: data = await state.get_data() tg_id = data.get("tg_id") email = data.get("email") + key_ref = data.get("key_ref") op_type = data.get("op_type") if op_type != "set" and (not message.text.isdigit() or int(message.text) < 0): await message.answer( text="🚫 Пожалуйста, введите корректное количество дней!", - reply_markup=build_users_key_show_kb(tg_id, email), + reply_markup=build_users_key_show_kb(tg_id, key_ref) if key_ref else build_editor_kb(tg_id), ) return @@ -347,7 +424,10 @@ async def handle_expiry_time_input(message: Message, state: FSMContext, session: except Exception as e: text = f"❗ Произошла ошибка во время изменения времени действия ключа: {e}" - await message.answer(text=text, reply_markup=build_users_key_show_kb(tg_id, email)) + await message.answer( + text=text, + reply_markup=build_users_key_show_kb(tg_id, key_ref) if key_ref else build_editor_kb(tg_id), + ) @router.callback_query( @@ -357,9 +437,14 @@ async def handle_expiry_time_input(message: Message, state: FSMContext, session: async def handle_reissue_menu( callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, + session: AsyncSession, ): tg_id = callback_data.tg_id - email = callback_data.data + key_ref = str(callback_data.data) + key_obj = await _resolve_callback_key(session, tg_id, key_ref) + if not key_obj: + await callback_query.message.edit_text("🚫 Ключ не найден.", reply_markup=build_editor_kb(tg_id)) + return text = ( "🔄 Перевыпуск подписки\n\n" @@ -373,7 +458,7 @@ async def handle_reissue_menu( await callback_query.message.edit_text( text=text, - reply_markup=build_reissue_menu_kb(email, tg_id), + reply_markup=build_reissue_menu_kb(key_ref, tg_id), ) @@ -387,14 +472,19 @@ async def handle_update_key( session: AsyncSession, ): tg_id = callback_data.tg_id - email = callback_data.data + key_ref = str(callback_data.data) + key_obj = await _resolve_callback_key(session, tg_id, key_ref) + if not key_obj: + await callback_query.message.edit_text("🚫 Ключ не найден.", reply_markup=build_editor_kb(tg_id)) + return + email = key_obj.email await callback_query.message.edit_text( text=f"📡 Выберите кластер, на котором пересоздать ключ {email}:", reply_markup=await build_cluster_selection_kb( session, tg_id, - email, + key_ref, action="confirm_admin_key_reissue", ), ) @@ -402,8 +492,13 @@ async def handle_update_key( @router.callback_query(F.data.startswith("confirm_admin_key_reissue|"), IsAdminFilter()) async def confirm_admin_key_reissue(callback_query: CallbackQuery, session: AsyncSession, state: FSMContext): - _, tg_id, email, cluster_id = callback_query.data.split("|") + _, tg_id, key_ref, cluster_id = callback_query.data.split("|") tg_id = int(tg_id) + key_obj = await _resolve_callback_key(session, tg_id, key_ref) + if not key_obj: + await callback_query.message.edit_text("🚫 Ключ не найден.", reply_markup=build_editor_kb(tg_id)) + return + email = key_obj.email try: servers = await get_servers(session) @@ -424,7 +519,7 @@ async def confirm_admin_key_reissue(callback_query: CallbackQuery, session: Asyn callback_data=AdminUserEditorCallback( action="users_key_edit", tg_id=tg_id, - data=email, + data=key_ref, ).pack(), ) ) @@ -442,17 +537,21 @@ async def confirm_admin_key_reissue(callback_query: CallbackQuery, session: Asyn if use_country_selection: unique_countries = {srv["server_name"] for srv in cluster_servers} - await state.update_data(tg_id=tg_id, email=email, cluster_id=cluster_id) + await state.update_data(tg_id=tg_id, email=email, key_ref=key_ref, cluster_id=cluster_id) builder = InlineKeyboardBuilder() for country in sorted(unique_countries): builder.button( text=country, - callback_data=f"admin_reissue_country|{tg_id}|{email}|{country}", + callback_data=f"admin_reissue_country|{tg_id}|{key_ref}|{country}", ) builder.row( InlineKeyboardButton( text=BACK, - callback_data=f"users_key_edit|{email}", + callback_data=AdminUserEditorCallback( + action="users_key_edit", + tg_id=tg_id, + data=key_ref, + ).pack(), ) ) await callback_query.message.edit_text( @@ -461,8 +560,8 @@ async def confirm_admin_key_reissue(callback_query: CallbackQuery, session: Asyn ) return - result = await session.execute(select(Key.remnawave_link).where(Key.email == email)) - remnawave_link = result.scalar_one_or_none() + key_link = await get_key_by_email(session, email) + remnawave_link = key_link.remnawave_link if key_link else None await update_subscription( tg_id, @@ -474,7 +573,7 @@ async def confirm_admin_key_reissue(callback_query: CallbackQuery, session: Asyn await handle_key_edit( callback_query, - AdminUserEditorCallback(tg_id=tg_id, data=email, action="view_key"), + AdminUserEditorCallback(tg_id=tg_id, data=key_ref, action="view_key"), session, True, ) @@ -485,8 +584,13 @@ async def confirm_admin_key_reissue(callback_query: CallbackQuery, session: Asyn @router.callback_query(F.data.startswith("admin_reissue_country|"), IsAdminFilter()) async def admin_reissue_country(callback_query: CallbackQuery, session: AsyncSession, state: FSMContext): - _, tg_id, email, country = callback_query.data.split("|") + _, tg_id, key_ref, country = callback_query.data.split("|") tg_id = int(tg_id) + key_obj = await _resolve_callback_key(session, tg_id, key_ref) + if not key_obj: + await callback_query.message.edit_text("🚫 Ключ не найден.", reply_markup=build_editor_kb(tg_id)) + return + email = key_obj.email try: data = await state.get_data() @@ -508,7 +612,7 @@ async def admin_reissue_country(callback_query: CallbackQuery, session: AsyncSes callback_data=AdminUserEditorCallback( action="users_key_edit", tg_id=tg_id, - data=email, + data=key_ref, ).pack(), ) ) @@ -522,8 +626,8 @@ async def admin_reissue_country(callback_query: CallbackQuery, session: AsyncSes ) return - result = await session.execute(select(Key.remnawave_link, Key.tariff_id).where(Key.email == email)) - remnawave_link, _tariff_id = result.one_or_none() or (None, None) + key_link = await get_key_by_email(session, email) + remnawave_link = key_link.remnawave_link if key_link else None await update_subscription( tg_id=tg_id, @@ -535,7 +639,7 @@ async def admin_reissue_country(callback_query: CallbackQuery, session: AsyncSes await handle_key_edit( callback_query, - AdminUserEditorCallback(tg_id=tg_id, data=email, action="view_key"), + AdminUserEditorCallback(tg_id=tg_id, data=key_ref, action="view_key"), session, True, ) @@ -554,10 +658,8 @@ async def handle_recreate_key_start( session: AsyncSession, ): tg_id = callback_data.tg_id - email = callback_data.data - - result = await session.execute(select(Key).where(Key.email == email)) - key_obj: Key | None = result.scalar_one_or_none() + key_ref = str(callback_data.data) + key_obj = await _resolve_callback_key(session, tg_id, key_ref) if not key_obj: await callback_query.message.edit_text( @@ -566,6 +668,8 @@ async def handle_recreate_key_start( ) return + email = key_obj.email + tariff_name = "—" if key_obj.tariff_id: tariff = await get_tariff_by_id(session, key_obj.tariff_id) @@ -584,13 +688,13 @@ async def handle_recreate_key_start( builder.row( InlineKeyboardButton( text="✅ Пересоздать", - callback_data=f"confirm_recreate|{tg_id}|{email}", + callback_data=f"confirm_recreate|{tg_id}|{key_ref}", ) ) builder.row( InlineKeyboardButton( text=BACK, - callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(), + callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=key_ref).pack(), ) ) @@ -602,12 +706,11 @@ async def handle_recreate_key_confirm( callback_query: CallbackQuery, session: AsyncSession, ): - _, tg_id, old_email = callback_query.data.split("|") + _, tg_id, key_ref = callback_query.data.split("|") tg_id = int(tg_id) try: - result = await session.execute(select(Key).where(Key.email == old_email)) - key_obj: Key | None = result.scalar_one_or_none() + key_obj = await _resolve_callback_key(session, tg_id, key_ref) if not key_obj: await callback_query.message.edit_text( @@ -616,6 +719,8 @@ async def handle_recreate_key_confirm( ) return + old_email = key_obj.email + await callback_query.message.edit_text("⏳ Пересоздание ссылки подписки...") client_id = key_obj.client_id @@ -683,15 +788,7 @@ async def handle_recreate_key_confirm( ) return - await session.execute( - update(Key) - .where(Key.email == old_email) - .values( - key=new_link, - remnawave_link=new_link, - ) - ) - await session.commit() + await update_key_subscription_links(session, old_email, new_link) try: user_text = ( @@ -740,7 +837,7 @@ async def handle_recreate_key_confirm( callback_data=AdminUserEditorCallback( action="users_key_edit", tg_id=tg_id, - data=old_email, + data=key_ref, ).pack(), ) ) @@ -768,10 +865,16 @@ async def handle_delete_key( state: FSMContext, session: AsyncSession, ): - email = callback_data.data + key_obj = await _resolve_callback_key(session, callback_data.tg_id, callback_data.data) + if not key_obj: + await callback_query.message.edit_text( + text="🚫 Ключ не найден!", + reply_markup=build_editor_kb(callback_data.tg_id), + ) + return - result = await session.execute(select(Key.client_id).where(Key.email == email)) - client_id = result.scalar_one_or_none() + email = key_obj.email + client_id = key_obj.client_id if client_id is None: await callback_query.message.edit_text( @@ -809,8 +912,7 @@ async def handle_delete_key_confirm( await callback_query.answer("Данные устарели", show_alert=True) return - result = await session.execute(select(Key.client_id).where(Key.email == email)) - client_id = result.scalar_one_or_none() + client_id = key_obj.client_id kb = build_editor_kb(callback_data.tg_id) @@ -862,8 +964,7 @@ async def handle_delete_user_confirm( ): tg_id = callback_data.tg_id - result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id)) - key_records = result.all() + key_records = [(row.email, row.client_id) for row in await get_keys(session, tg_id)] await release_session_early(session) async def delete_keys_from_servers(): @@ -903,7 +1004,11 @@ async def handle_user_traffic( session: AsyncSession, ): tg_id = callback_data.tg_id - email = callback_data.data + key_obj = await _resolve_callback_key(session, tg_id, callback_data.data) + if not key_obj: + await callback_query.message.edit_text("❌ Ключ не найден.", reply_markup=build_editor_kb(tg_id)) + return + email = key_obj.email await callback_query.message.edit_text("⏳ Получаем данные о трафике, пожалуйста, подождите...") @@ -952,9 +1057,7 @@ async def handle_create_key_start( if use_country_selection: await state.set_state(UserEditorState.selecting_country) - stmt = select(Server.server_name).distinct().order_by(Server.server_name) - result = await session.execute(stmt) - countries = [row[0] for row in result.all()] + countries = await get_server_names(session) if not countries: await callback_query.message.edit_text( @@ -1007,14 +1110,13 @@ async def handle_create_key_country(callback_query: CallbackQuery, state: FSMCon builder = InlineKeyboardBuilder() - result = await session.execute(select(Server.cluster_name).where(Server.server_name == country)) - row = result.mappings().first() + cluster_info = await check_server_name_by_cluster(session, country) - if not row: + if not cluster_info: await callback_query.message.edit_text("❌ Сервер не найден.") return - cluster_name = row["cluster_name"] + cluster_name = cluster_info["cluster_name"] await state.update_data(cluster_name=cluster_name) tariffs = await get_tariffs_for_cluster(session, cluster_name) @@ -1146,20 +1248,16 @@ async def handle_reset_traffic( session: AsyncSession, ): tg_id = callback_data.tg_id - email = callback_data.data - - stmt = select(Key.server_id, Key.client_id).where((Key.tg_id == tg_id) & (Key.email == email)) - result = await session.execute(stmt) - record = result.first() - - if not record: + key_obj = await _resolve_callback_key(session, tg_id, callback_data.data) + if not key_obj: await callback_query.message.edit_text( "❌ Ключ не найден в базе данных.", reply_markup=build_editor_kb(tg_id), ) return - cluster_id, _client_id = record + email = key_obj.email + cluster_id = key_obj.server_id try: await reset_traffic_in_cluster(cluster_id, email, session) @@ -1185,7 +1283,14 @@ async def handle_admin_freeze_subscription( session: AsyncSession, ): tg_id = callback_data.tg_id - email = str(callback_data.data) + key_obj = await _resolve_callback_key(session, tg_id, callback_data.data) + if not key_obj: + await callback_query.message.edit_text( + text="🚫 Информация о ключе не найдена.", + reply_markup=build_editor_kb(tg_id), + ) + return + email = key_obj.email try: record = await get_key_details(session, email) @@ -1202,7 +1307,7 @@ async def handle_admin_freeze_subscription( result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False, session=session) if result["status"] != "success": text_error = ( - f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}" + f"Произошла ошибка при отключении подписки.\nДетали: {result.get('error') or result.get('results')}" ) await callback_query.message.edit_text( text_error, @@ -1217,8 +1322,9 @@ async def handle_admin_freeze_subscription( await mark_key_as_frozen(session, record["tg_id"], client_id, time_left) await session.commit() + session.expire_all() - await callback_query.answer("✅ Подписка заморожена") + await callback_query.answer("✅ Подписка отключена") await handle_key_edit( callback_query=callback_query, @@ -1227,7 +1333,7 @@ async def handle_admin_freeze_subscription( update=False, ) except Exception as e: - await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}") + await handle_error(tg_id, callback_query, f"Ошибка при отключении подписки: {e}") @router.callback_query( @@ -1240,7 +1346,14 @@ async def handle_admin_unfreeze_subscription( session: AsyncSession, ): tg_id = callback_data.tg_id - email = str(callback_data.data) + key_obj = await _resolve_callback_key(session, tg_id, callback_data.data) + if not key_obj: + await callback_query.message.edit_text( + text="🚫 Информация о ключе не найдена.", + reply_markup=build_editor_kb(tg_id), + ) + return + email = key_obj.email try: record = await get_key_details(session, email) @@ -1286,6 +1399,7 @@ async def handle_admin_unfreeze_subscription( await mark_key_as_unfrozen(session, record["tg_id"], client_id, new_expiry_time) await session.commit() + session.expire_all() await release_session_early(session) await renew_key_in_cluster( @@ -1300,7 +1414,7 @@ async def handle_admin_unfreeze_subscription( plan=record.get("tariff_id"), ) - await callback_query.answer("✅ Подписка разморожена") + await callback_query.answer("✅ Подписка включена") await handle_key_edit( callback_query=callback_query, @@ -1313,20 +1427,15 @@ async def handle_admin_unfreeze_subscription( async def change_expiry_time(expiry_time: int, email: str, session: AsyncSession) -> Exception | None: - result = await session.execute( - select( - Key.client_id, - Key.tariff_id, - Key.server_id, - Key.current_device_limit, - Key.current_traffic_limit, - ).where(Key.email == email) - ) - row = result.first() - if not row: + key_obj = await get_key_by_email(session, email) + if not key_obj: return ValueError(f"User with email {email} was not found") - client_id, tariff_id, server_id, key_device_limit, key_traffic_limit = row + client_id = key_obj.client_id + tariff_id = key_obj.tariff_id + server_id = key_obj.server_id + key_device_limit = key_obj.current_device_limit + key_traffic_limit = key_obj.current_traffic_limit if server_id is None: return ValueError(f"Key with client_id {client_id} was not found") @@ -1334,17 +1443,12 @@ async def change_expiry_time(expiry_time: int, email: str, session: AsyncSession device_limit = None key_subgroup = None if tariff_id: - result = await session.execute( - select(Tariff.traffic_limit, Tariff.device_limit, Tariff.subgroup_title).where( - Tariff.id == tariff_id, - Tariff.is_active.is_(True), - ) - ) - tariff = result.first() + tariff = await get_tariff_by_id(session, tariff_id) if tariff: - traffic_limit = int(tariff[0]) if tariff[0] is not None else 0 - device_limit = int(tariff[1]) if tariff[1] is not None else 0 - key_subgroup = tariff[2] + traffic_limit = int(tariff.get("traffic_limit") or 0) + raw_device_limit = tariff.get("device_limit") + device_limit = int(raw_device_limit) if raw_device_limit is not None else 0 + key_subgroup = tariff.get("subgroup_title") if key_device_limit is not None: device_limit = key_device_limit @@ -1395,16 +1499,17 @@ async def handle_edit_config_start( state: FSMContext, session: AsyncSession, ): - email = callback_data.data + key_ref = str(callback_data.data) tg_id = callback_data.tg_id - result = await session.execute(select(Key).where(Key.email == email)) - key_obj: Key | None = result.scalar_one_or_none() + key_obj = await _resolve_callback_key(session, tg_id, key_ref) if not key_obj: await callback_query.message.edit_text("❌ Ключ не найден.", reply_markup=build_editor_kb(tg_id)) return + email = key_obj.email + if not key_obj.tariff_id: await callback_query.message.edit_text( "❌ У ключа не назначен тариф.", @@ -1431,6 +1536,7 @@ async def handle_edit_config_start( await state.set_state(UserEditorState.config_menu) await state.update_data( email=email, + key_ref=key_ref, tg_id=tg_id, tariff_id=key_obj.tariff_id, cfg_base_devices=base_devices, @@ -1445,6 +1551,7 @@ async def handle_edit_config_start( async def render_config_menu(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession): data = await state.get_data() email = data.get("email") + key_ref = data.get("key_ref") tg_id = data.get("tg_id") tariff_id = data.get("tariff_id") @@ -1461,8 +1568,7 @@ async def render_config_menu(callback_query: CallbackQuery, state: FSMContext, s traffic_to_show = base_traffic if traffic_to_show is None and email: - result = await session.execute(select(Key).where(Key.email == email)) - key_obj = result.scalar_one_or_none() + key_obj = await get_key_by_email(session, email) if key_obj: traffic_to_show = key_obj.selected_traffic_limit or key_obj.current_traffic_limit if traffic_to_show is None and tariff: @@ -1501,7 +1607,7 @@ async def render_config_menu(callback_query: CallbackQuery, state: FSMContext, s builder.row( InlineKeyboardButton( text=BACK, - callback_data=AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id).pack(), + callback_data=AdminUserEditorCallback(action="users_key_edit", data=key_ref, tg_id=tg_id).pack(), ) ) @@ -1668,6 +1774,7 @@ async def handle_cfg_input_addon(message: Message, state: FSMContext, session: A data = await state.get_data() param = data.get("cfg_param") email = data.get("email") + key_ref = data.get("key_ref") tg_id = data.get("tg_id") tariff_id = data.get("tariff_id") @@ -1721,7 +1828,7 @@ async def handle_cfg_input_addon(message: Message, state: FSMContext, session: A builder.row( InlineKeyboardButton( text=BACK, - callback_data=AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id).pack(), + callback_data=AdminUserEditorCallback(action="users_key_edit", data=key_ref, tg_id=tg_id).pack(), ) ) @@ -1760,8 +1867,7 @@ async def handle_cfg_save(callback_query: CallbackQuery, state: FSMContext, sess selected_price = base_price + devices_extra_price + traffic_extra_price - result = await session.execute(select(Key).where(Key.email == email)) - key_obj: Key | None = result.scalar_one_or_none() + key_obj = await get_key_by_email(session, email) if not key_obj: await callback_query.message.edit_text("❌ Ключ не найден.", reply_markup=build_editor_kb(tg_id)) @@ -1782,18 +1888,15 @@ async def handle_cfg_save(callback_query: CallbackQuery, state: FSMContext, sess plan=tariff_id, ) - await session.execute( - update(Key) - .where(Key.email == email) - .values( - selected_device_limit=base_devices, - current_device_limit=total_devices, - selected_traffic_limit=base_traffic, - current_traffic_limit=total_traffic, - selected_price_rub=selected_price, - ) + await save_admin_key_config( + session, + email=email, + base_devices=base_devices, + total_devices=total_devices, + base_traffic=base_traffic, + total_traffic=total_traffic, + selected_price=selected_price, ) - await session.commit() await state.clear() await callback_query.answer("✅ Конфигурация сохранена", show_alert=True) diff --git a/handlers/admin/users/users_tariffs.py b/handlers/admin/users/users_tariffs.py index 565868cf..2ef72c45 100644 --- a/handlers/admin/users/users_tariffs.py +++ b/handlers/admin/users/users_tariffs.py @@ -5,19 +5,26 @@ from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder -from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from handlers.buttons import BACK from core.settings.tariffs_config import normalize_tariff_config -from database import get_tariff_by_id -from database.models import Key, Tariff +from database import ( + get_active_tariffs_by_group_code, + get_key_by_email, + get_tariff_by_id, + get_tariff_group_codes, + reset_key_tariff_state, + save_key_tariff_selection, +) +from database.models import Tariff from filters.admin import IsAdminFilter from middlewares.session import release_session_early from handlers.keys.operations import renew_key_in_cluster from logger import logger -from .keyboard import AdminUserEditorCallback +from .keyboard import AdminUserEditorCallback, build_editor_kb +from .utils import resolve_admin_key from .users_states import RenewTariffState from .users_keys import handle_key_edit @@ -60,14 +67,17 @@ async def handle_user_choose_tariff_group( session: AsyncSession, state: FSMContext, ): - email = callback_data.data tg_id = callback_data.tg_id + key_obj = await resolve_admin_key(session, tg_id, callback_data.data) + if not key_obj: + await callback_query.message.edit_text("❌ Ключ не найден.", reply_markup=build_editor_kb(tg_id)) + return + email = key_obj.email await state.set_state(RenewTariffState.selecting_group) await state.update_data(email=email, tg_id=tg_id) - result = await session.execute(select(Tariff.group_code).distinct()) - groups = [row[0] for row in result.fetchall()] + groups = await get_tariff_group_codes(session) builder = InlineKeyboardBuilder() for group_code in groups: @@ -91,10 +101,7 @@ async def handle_user_choose_tariff( await state.update_data(group_code=group_code) await state.set_state(RenewTariffState.selecting_tariff) - result = await session.execute( - select(Tariff).where(Tariff.group_code == group_code, Tariff.is_active.is_(True)).order_by(Tariff.id) - ) - tariffs = result.scalars().all() + tariffs = await get_active_tariffs_by_group_code(session, group_code) if not tariffs: await callback_query.message.edit_text("❌ Нет активных тарифов в группе.") @@ -134,8 +141,7 @@ async def handle_user_renew_confirm( await state.clear() return - result = await session.execute(select(Key).where(Key.email == email, Key.tg_id == tg_id)) - key_obj: Key | None = result.scalar_one_or_none() + key_obj = await get_key_by_email(session, email, tg_id) if not key_obj: await callback_query.message.edit_text("❌ Ключ не найден.") await state.clear() @@ -311,29 +317,15 @@ async def handle_user_renew_confirm( old_tariff_id = key_obj.tariff_id old_subgroup = None if old_tariff_id: - old_subgroup = ( - await session.execute(select(Tariff.subgroup_title).where(Tariff.id == old_tariff_id)) - ).scalar_one_or_none() + old_tariff = await get_tariff_by_id(session, old_tariff_id) + old_subgroup = old_tariff.get("subgroup_title") if old_tariff else None - new_subgroup = ( - await session.execute(select(Tariff.subgroup_title).where(Tariff.id == tariff_id)) - ).scalar_one_or_none() + new_tariff = await get_tariff_by_id(session, tariff_id) + new_subgroup = new_tariff.get("subgroup_title") if new_tariff else None new_expiry_time = int(key_obj.expiry_time or 0) or int(datetime.utcnow().timestamp() * 1000) - await session.execute( - update(Key) - .where(Key.tg_id == tg_id, Key.email == email) - .values( - tariff_id=tariff_id, - selected_device_limit=None, - current_device_limit=None, - selected_traffic_limit=None, - current_traffic_limit=None, - selected_price_rub=None, - ) - ) - await session.commit() + await reset_key_tariff_state(session, tg_id, email, tariff_id) await release_session_early(session) try: @@ -629,8 +621,7 @@ async def handle_cfg_renew_apply(callback_query: CallbackQuery, session: AsyncSe await state.clear() return - result = await session.execute(select(Key).where(Key.email == email, Key.tg_id == tg_id)) - key_obj: Key | None = result.scalar_one_or_none() + key_obj = await get_key_by_email(session, email, tg_id) if not key_obj: await callback_query.message.edit_text("❌ Ключ не найден.") await state.clear() @@ -639,33 +630,15 @@ async def handle_cfg_renew_apply(callback_query: CallbackQuery, session: AsyncSe old_tariff_id = key_obj.tariff_id old_subgroup = None if old_tariff_id: - old_subgroup = ( - await session.execute(select(Tariff.subgroup_title).where(Tariff.id == old_tariff_id)) - ).scalar_one_or_none() + old_tariff = await get_tariff_by_id(session, old_tariff_id) + old_subgroup = old_tariff.get("subgroup_title") if old_tariff else None - new_subgroup = ( - await session.execute(select(Tariff.subgroup_title).where(Tariff.id == tariff_id)) - ).scalar_one_or_none() + new_tariff = await get_tariff_by_id(session, tariff_id) + new_subgroup = new_tariff.get("subgroup_title") if new_tariff else None new_expiry_time = int(key_obj.expiry_time or 0) or int(datetime.utcnow().timestamp() * 1000) - await session.execute( - update(Key) - .where(Key.tg_id == tg_id, Key.email == email) - .values( - tariff_id=tariff_id, - selected_device_limit=int(selected_devices) if selected_devices is not None else None, - current_device_limit=int(selected_devices) if selected_devices is not None else None, - selected_traffic_limit=int(selected_traffic_gb) - if (selected_traffic_gb is not None and int(selected_traffic_gb) > 0) - else None, - current_traffic_limit=int(selected_traffic_gb) - if (selected_traffic_gb is not None and int(selected_traffic_gb) > 0) - else None, - selected_price_rub=None, - ) - ) - await session.commit() + await save_key_tariff_selection(session, tg_id, email, tariff_id, selected_devices, selected_traffic_gb) await release_session_early(session) try: @@ -706,8 +679,7 @@ async def handle_back_to_group( state: FSMContext, session: AsyncSession, ): - result = await session.execute(select(Tariff.group_code).distinct()) - groups = [row[0] for row in result.fetchall()] + groups = await get_tariff_group_codes(session) builder = InlineKeyboardBuilder() for group_code in groups: diff --git a/handlers/admin/users/utils.py b/handlers/admin/users/utils.py new file mode 100644 index 00000000..a4ee2550 --- /dev/null +++ b/handlers/admin/users/utils.py @@ -0,0 +1,12 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from handlers.keys.utils import build_key_ref, resolve_key +from database.models import Key + + +def build_admin_key_ref(client_id: str | None, email: str | None = None) -> str: + return build_key_ref(client_id, email) + + +async def resolve_admin_key(session: AsyncSession, tg_id: int, key_ref: str | int | None) -> Key | None: + return await resolve_key(session, tg_id, key_ref) diff --git a/handlers/buttons.py b/handlers/buttons.py index 0e36a2ca..0e1e98fe 100644 --- a/handlers/buttons.py +++ b/handlers/buttons.py @@ -1,10 +1,4 @@ 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"}, # красная } # Общие кнопки @@ -45,8 +39,8 @@ HWID_BUTTON = "♻️ Сбросить привязку" QR = "📷 Показать QR-код" DELETE = "❌ Удалить" CHANGE_LOCATION = "🌍 Сменить локацию" -FREEZE = "🛑 Заморозить подписку" -UNFREEZE = "🟢 Разморозить подписку" +FREEZE = "Отключить подписку" +UNFREEZE = "Включить подписку" ALIAS = "✏️" # Реферальная система diff --git a/handlers/instructions/instructions.py b/handlers/instructions/instructions.py index e3a35665..f9f83cf4 100644 --- a/handlers/instructions/instructions.py +++ b/handlers/instructions/instructions.py @@ -42,7 +42,7 @@ from handlers.texts import ( ROUTER_MESSAGE, SUBSCRIPTION_DETAILS_TEXT, ) -from handlers.keys.utils import key_owned_by_user +from handlers.keys.utils import build_key_callback, key_owned_by_user, resolve_key from handlers.utils import edit_or_send_message, is_full_remnawave_cluster from hooks.processors import process_remnawave_webapp_override @@ -75,7 +75,9 @@ async def send_instructions(callback_query_or_message: CallbackQuery | Message): @router.callback_query(F.data.startswith("connect_pc|")) async def process_connect_pc(callback_query: CallbackQuery, session: Any): - key_name = callback_query.data.split("|")[1] + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + key_name = key_obj.email if key_obj else key_ref record = await get_key_details(session, key_name) if not key_owned_by_user(record, callback_query.from_user.id): await callback_query.answer("Доступ запрещён.", show_alert=True) @@ -93,9 +95,9 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any): return builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=PC_PC, callback_data=f"windows_menu|{key_name}")) - builder.row(InlineKeyboardButton(text=PC_MACOS, callback_data=f"macos_menu|{key_name}")) - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{key_name}")) + builder.row(InlineKeyboardButton(text=PC_PC, callback_data=build_key_callback("windows_menu", record.get("client_id"), key_name))) + builder.row(InlineKeyboardButton(text=PC_MACOS, callback_data=build_key_callback("macos_menu", record.get("client_id"), key_name))) + builder.row(InlineKeyboardButton(text=BACK, callback_data=build_key_callback("connect_device", record.get("client_id"), key_name))) await edit_or_send_message( target_message=callback_query.message, @@ -107,7 +109,9 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any): @router.callback_query(F.data.startswith("windows_menu|")) async def process_windows_menu(callback_query: CallbackQuery, session: Any): - key_name = callback_query.data.split("|")[1] + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + key_name = key_obj.email if key_obj else key_ref record = await get_key_details(session, key_name) if not key_owned_by_user(record, callback_query.from_user.id): await callback_query.answer("Доступ запрещён.", show_alert=True) @@ -132,7 +136,7 @@ async def process_windows_menu(callback_query: CallbackQuery, session: Any): builder.row(InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=windows_url)) builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL)) - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data=build_key_callback("connect_pc", record.get("client_id"), key_name))) await edit_or_send_message( target_message=callback_query.message, @@ -144,7 +148,9 @@ async def process_windows_menu(callback_query: CallbackQuery, session: Any): @router.callback_query(F.data.startswith("macos_menu|")) async def process_macos_menu(callback_query: CallbackQuery, session: Any): - key_name = callback_query.data.split("|")[1] + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + key_name = key_obj.email if key_obj else key_ref record = await get_key_details(session, key_name) if not key_owned_by_user(record, callback_query.from_user.id): await callback_query.answer("Доступ запрещён.", show_alert=True) @@ -169,7 +175,7 @@ async def process_macos_menu(callback_query: CallbackQuery, session: Any): builder.row(InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=macos_url)) builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL)) - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data=build_key_callback("connect_pc", record.get("client_id"), key_name))) await edit_or_send_message( target_message=callback_query.message, @@ -181,7 +187,9 @@ async def process_macos_menu(callback_query: CallbackQuery, session: Any): @router.callback_query(F.data.startswith("connect_tv|")) async def process_connect_tv(callback_query: CallbackQuery, session: Any): - key_name = callback_query.data.split("|", 1)[1] + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + key_name = key_obj.email if key_obj else key_ref record = await get_key_details(session, key_name) if not key_owned_by_user(record, callback_query.from_user.id): @@ -216,10 +224,14 @@ async def process_connect_tv(callback_query: CallbackQuery, session: Any): await callback_query.answer("❌ Ссылка подписки не найдена", show_alert=True) return - back_callback = f"view_key|{key_name}" if is_remnawave_webapp else f"connect_device|{key_name}" + back_callback = ( + build_key_callback("view_key", record.get("client_id"), key_name) + if is_remnawave_webapp + else build_key_callback("connect_device", record.get("client_id"), key_name) + ) builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=TV_CONTINUE, callback_data=f"continue_tv|{key_name}")) + builder.row(InlineKeyboardButton(text=TV_CONTINUE, callback_data=build_key_callback("continue_tv", record.get("client_id"), key_name))) builder.row(InlineKeyboardButton(text=BACK, callback_data=back_callback)) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) @@ -236,7 +248,9 @@ async def process_connect_tv(callback_query: CallbackQuery, session: Any): @router.callback_query(F.data.startswith("continue_tv|")) async def process_continue_tv(callback_query: CallbackQuery, session: Any): - key_name = callback_query.data.split("|")[1] + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + key_name = key_obj.email if key_obj else key_ref record = await get_key_details(session, key_name) if not key_owned_by_user(record, callback_query.from_user.id): await callback_query.answer("Доступ запрещён.", show_alert=True) @@ -245,7 +259,7 @@ async def process_continue_tv(callback_query: CallbackQuery, session: Any): message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=key_link) builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_tv|{key_name}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data=build_key_callback("connect_tv", record.get("client_id"), key_name))) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) await edit_or_send_message( @@ -258,7 +272,9 @@ async def process_continue_tv(callback_query: CallbackQuery, session: Any): @router.callback_query(F.data.startswith("connect_router|")) async def process_connect_router(callback_query: CallbackQuery, session: Any): - key_name = callback_query.data.split("|")[1] + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + key_name = key_obj.email if key_obj else key_ref record = await get_key_details(session, key_name) if not key_owned_by_user(record, callback_query.from_user.id): await callback_query.answer("Доступ запрещён.", show_alert=True) @@ -278,7 +294,7 @@ async def process_connect_router(callback_query: CallbackQuery, session: Any): message_text = ROUTER_MESSAGE.format(subscription_link=key_link) builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data=build_key_callback("view_key", record.get("client_id"), key_name))) await edit_or_send_message( target_message=callback_query.message, diff --git a/handlers/keys/__init__.py b/handlers/keys/__init__.py index 959efc08..766cb2f8 100644 --- a/handlers/keys/__init__.py +++ b/handlers/keys/__init__.py @@ -3,7 +3,6 @@ __all__ = ("router",) from aiogram import Router from .key_connect import router as connect_router -from .key_freeze import router as freeze_router from .key_mode import router as key_mode_router from .key_renew import router as renew_router from .key_view import router as view_router @@ -16,7 +15,6 @@ router.include_routers( keys_router, view_router, renew_router, - freeze_router, connect_router, key_mode_router, ) diff --git a/handlers/keys/key_connect.py b/handlers/keys/key_connect.py index 90bc7d94..bbd53f75 100644 --- a/handlers/keys/key_connect.py +++ b/handlers/keys/key_connect.py @@ -8,7 +8,6 @@ import qrcode from aiogram import F, Router, types from aiogram.types import CallbackQuery, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from config import ( @@ -19,7 +18,7 @@ from config import ( DOWNLOAD_IOS, INSTRUCTIONS_BUTTON, ) -from database import Key, get_key_details, get_subscription_link +from database import get_key_details, get_subscription_link from handlers.buttons import ( ANDROID, BACK, @@ -39,7 +38,7 @@ from handlers.texts import ( IOS_DESCRIPTION_TEMPLATE, SUBSCRIPTION_DESCRIPTION, ) -from handlers.keys.utils import key_owned_by_user +from handlers.keys.utils import build_key_callback, key_owned_by_user, resolve_key from handlers.utils import edit_or_send_message from hooks.hook_buttons import insert_hook_buttons from hooks.processors import process_connect_device_menu @@ -67,18 +66,23 @@ def generate_key_qr_file(qr_data: str, email: str) -> str: @router.callback_query(F.data.startswith("connect_device|")) async def handle_connect_device(callback_query: CallbackQuery, session: AsyncSession): try: - key_name = callback_query.data.split("|")[1] + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + key_name = key_obj.email if key_obj else key_ref record = await get_key_details(session, key_name) if not key_owned_by_user(record, callback_query.from_user.id): await callback_query.answer("Доступ запрещён.", show_alert=True) return builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}")) - builder.row(InlineKeyboardButton(text=ANDROID, callback_data=f"connect_android|{key_name}")) - builder.row(InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}")) - builder.row(InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}")) - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")) + client_id = record.get("client_id") + builder.row(InlineKeyboardButton(text=IPHONE, callback_data=build_key_callback("connect_ios", client_id, key_name))) + builder.row( + InlineKeyboardButton(text=ANDROID, callback_data=build_key_callback("connect_android", client_id, key_name)) + ) + builder.row(InlineKeyboardButton(text=PC, callback_data=build_key_callback("connect_pc", client_id, key_name))) + builder.row(InlineKeyboardButton(text=TV, callback_data=build_key_callback("connect_tv", client_id, key_name))) + builder.row(InlineKeyboardButton(text=BACK, callback_data=build_key_callback("view_key", client_id, key_name))) hook_builder = InlineKeyboardBuilder() hook_builder.attach(builder) @@ -104,7 +108,9 @@ async def handle_connect_device(callback_query: CallbackQuery, session: AsyncSes @router.callback_query(F.data.startswith("connect_phone|")) async def process_callback_connect_phone(callback_query: CallbackQuery, session: AsyncSession): - email = callback_query.data.split("|")[1] + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + email = key_obj.email if key_obj else key_ref try: record = await get_key_details(session, email) @@ -142,7 +148,7 @@ async def process_callback_connect_phone(callback_query: CallbackQuery, session: ) if INSTRUCTIONS_BUTTON: builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions")) - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data=build_key_callback("view_key", record.get("client_id"), email))) await edit_or_send_message( target_message=callback_query.message, @@ -154,7 +160,9 @@ async def process_callback_connect_phone(callback_query: CallbackQuery, session: @router.callback_query(F.data.startswith("connect_ios|")) async def process_callback_connect_ios(callback_query: CallbackQuery, session: AsyncSession): - email = callback_query.data.split("|")[1] + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + email = key_obj.email if key_obj else key_ref try: record = await get_key_details(session, email) @@ -185,7 +193,9 @@ async def process_callback_connect_ios(callback_query: CallbackQuery, session: A builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=ios_url)) if INSTRUCTIONS_BUTTON: builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions")) - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}")) + builder.row( + InlineKeyboardButton(text=BACK, callback_data=build_key_callback("connect_device", record.get("client_id"), email)) + ) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) await edit_or_send_message( @@ -198,7 +208,9 @@ async def process_callback_connect_ios(callback_query: CallbackQuery, session: A @router.callback_query(F.data.startswith("connect_android|")) async def process_callback_connect_android(callback_query: CallbackQuery, session: AsyncSession): - email = callback_query.data.split("|")[1] + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + email = key_obj.email if key_obj else key_ref try: record = await get_key_details(session, email) @@ -229,7 +241,9 @@ async def process_callback_connect_android(callback_query: CallbackQuery, sessio builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=android_url)) if INSTRUCTIONS_BUTTON: builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions")) - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}")) + builder.row( + InlineKeyboardButton(text=BACK, callback_data=build_key_callback("connect_device", record.get("client_id"), email)) + ) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) await edit_or_send_message( @@ -243,11 +257,8 @@ async def process_callback_connect_android(callback_query: CallbackQuery, sessio @router.callback_query(F.data.startswith("show_qr|")) async def show_qr_code(callback_query: types.CallbackQuery, session: AsyncSession): try: - key_name = callback_query.data.split("|")[1] - - stmt = select(Key).where(Key.email == key_name) - result = await session.execute(stmt) - record = result.scalars().first() + key_ref = callback_query.data.split("|", 1)[1] + record = await resolve_key(session, callback_query.from_user.id, key_ref) if not record: await callback_query.message.answer("❌ Подписка не найдена.") @@ -266,7 +277,12 @@ async def show_qr_code(callback_query: types.CallbackQuery, session: AsyncSessio qr_path = await run_cpu(generate_key_qr_file, qr_data, record.email) builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record.email}")) + builder.row( + InlineKeyboardButton( + text=BACK, + callback_data=build_key_callback("view_key", record.client_id, record.email), + ) + ) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) await edit_or_send_message( diff --git a/handlers/keys/key_freeze.py b/handlers/keys/key_freeze.py deleted file mode 100644 index ecf487f6..00000000 --- a/handlers/keys/key_freeze.py +++ /dev/null @@ -1,228 +0,0 @@ -import time - -from typing import Any - -from aiogram import F, Router -from aiogram.types import CallbackQuery, InlineKeyboardButton -from aiogram.utils.keyboard import InlineKeyboardBuilder - -from database import ( - get_key_details, - get_servers, - get_tariff_by_id, - mark_key_as_frozen, - mark_key_as_unfrozen, -) -from handlers.buttons import APPLY, BACK, CANCEL -from handlers.keys.operations import renew_key_in_cluster, toggle_client_on_cluster -from handlers.texts import ( - FREEZE_SUBSCRIPTION_CONFIRM_MSG, - SUBSCRIPTION_FROZEN_MSG, - SUBSCRIPTION_UNFROZEN_MSG, - UNFREEZE_SUBSCRIPTION_CONFIRM_MSG, -) -from handlers.keys.utils import key_owned_by_user -from handlers.utils import edit_or_send_message, handle_error -from middlewares.session import release_session_early -from logger import logger - - -router = Router() - - -@router.callback_query(F.data.startswith("unfreeze_subscription|")) -async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any): - key_name = callback_query.data.split("|")[1] - record = await get_key_details(session, key_name) - if not key_owned_by_user(record, callback_query.from_user.id): - await callback_query.answer("Доступ запрещён.", show_alert=True) - return - confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text=APPLY, - callback_data=f"unfreeze_subscription_confirm|{key_name}", - ), - InlineKeyboardButton( - text=CANCEL, - callback_data=f"view_key|{key_name}", - ), - ) - - await edit_or_send_message( - target_message=callback_query.message, - text=confirm_text, - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|")) -async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any): - """ - Размораживает (включает) подписку. - """ - tg_id = callback_query.message.chat.id - key_name = callback_query.data.split("|")[1] - - try: - record = await get_key_details(session, key_name) - if not record: - await callback_query.message.answer("Ключ не найден.") - return - if not key_owned_by_user(record, callback_query.from_user.id): - await callback_query.answer("Доступ запрещён.", show_alert=True) - return - - email = record["email"] - client_id = record["client_id"] - cluster_id = record["server_id"] - - result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True, session=session) - if result["status"] != "success": - logger.warning(f"Не удалось включить подписку: {result.get('error') or result.get('results')}") - - servers = await get_servers(session) - cluster_servers = servers.get(cluster_id, []) - - if not cluster_servers: - text_error = "Сервер не найден." - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")) - await edit_or_send_message(callback_query.message, text_error, builder.as_markup()) - return - - tariff = await get_tariff_by_id(session, record["tariff_id"]) if record.get("tariff_id") else None - - if not tariff: - logger.info("[Unfreeze] Тариф не найден — применяем дефолтные значения.") - total_gb = 0 - hwid_limit = 0 - else: - total_gb = int(tariff.get("traffic_limit") or 0) - hwid_limit = int(tariff.get("device_limit") or 0) - - now_ms = int(time.time() * 1000) - leftover = record["expiry_time"] - if leftover < 0: - leftover = 0 - new_expiry_time = now_ms + leftover - - await mark_key_as_unfrozen(session, record["tg_id"], client_id, new_expiry_time) - await session.commit() - await release_session_early(session) - - max(leftover / (1000 * 86400), 0.01) - logger.info( - f"[Unfreeze Debug] Запуск renew_key_in_cluster с expiry={new_expiry_time}, gb={total_gb}, hwid={hwid_limit}" - ) - - await renew_key_in_cluster( - cluster_id=cluster_id, - email=email, - client_id=client_id, - new_expiry_time=new_expiry_time, - total_gb=total_gb, - session=session, - hwid_device_limit=hwid_limit, - reset_traffic=False, - plan=record.get("tariff_id"), - ) - - text_ok = SUBSCRIPTION_UNFROZEN_MSG - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")) - await edit_or_send_message(callback_query.message, text_ok, builder.as_markup()) - - except Exception as e: - await handle_error(tg_id, callback_query, f"Ошибка при включении подписки: {e}") - - -@router.callback_query(F.data.startswith("freeze_subscription|")) -async def process_callback_freeze_subscription(callback_query: CallbackQuery, session: Any): - """ - Показывает пользователю диалог подтверждения заморозки (отключения) подписки. - """ - key_name = callback_query.data.split("|")[1] - record = await get_key_details(session, key_name) - if not key_owned_by_user(record, callback_query.from_user.id): - await callback_query.answer("Доступ запрещён.", show_alert=True) - return - - confirm_text = FREEZE_SUBSCRIPTION_CONFIRM_MSG - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text=APPLY, - callback_data=f"freeze_subscription_confirm|{key_name}", - ), - InlineKeyboardButton( - text=CANCEL, - callback_data=f"view_key|{key_name}", - ), - ) - - await edit_or_send_message( - target_message=callback_query.message, - text=confirm_text, - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data.startswith("freeze_subscription_confirm|")) -async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any): - """ - Замораживает (отключает) подписку. - """ - tg_id = callback_query.message.chat.id - key_name = callback_query.data.split("|")[1] - - try: - record = await get_key_details(session, key_name) - if not record: - await callback_query.message.answer("Ключ не найден.") - return - if not key_owned_by_user(record, callback_query.from_user.id): - await callback_query.answer("Доступ запрещён.", show_alert=True) - return - - email = record["email"] - client_id = record["client_id"] - cluster_id = record["server_id"] - - result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False, session=session) - - if result["status"] == "success": - now_ms = int(time.time() * 1000) - time_left = record["expiry_time"] - now_ms - if time_left < 0: - time_left = 0 - - await mark_key_as_frozen(session, record["tg_id"], client_id, time_left) - await session.commit() - - text_ok = SUBSCRIPTION_FROZEN_MSG - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")) - await edit_or_send_message( - target_message=callback_query.message, - text=text_ok, - reply_markup=builder.as_markup(), - ) - - else: - text_error = ( - f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}" - ) - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")) - await edit_or_send_message( - target_message=callback_query.message, - text=text_error, - reply_markup=builder.as_markup(), - ) - - except Exception as e: - await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}") diff --git a/handlers/keys/key_mode/key_cluster_mode.py b/handlers/keys/key_mode/key_cluster_mode.py index 3d9bbc65..9c57ee27 100644 --- a/handlers/keys/key_mode/key_cluster_mode.py +++ b/handlers/keys/key_mode/key_cluster_mode.py @@ -33,6 +33,7 @@ from handlers.buttons import ( TV_BUTTON, ) from handlers.keys.operations import create_key_on_cluster +from handlers.keys.utils import build_key_callback from database import get_vless_enabled from handlers.tariffs.tariff_display import ( build_key_created_message, @@ -214,7 +215,7 @@ async def key_cluster_mode( builder = InlineKeyboardBuilder() if vless_enabled: - builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}")) + builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=build_key_callback("connect_router", client_id, key_name))) else: if await is_full_remnawave_cluster(least_loaded_cluster, session): use_webapp = bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP)) @@ -239,13 +240,13 @@ async def key_cluster_mode( else: builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link))) if tv_button_enabled: - builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")) + builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=build_key_callback("connect_tv", client_id, email))) else: - builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}")) + builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=build_key_callback("connect_device", client_id, key_name))) else: - builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}")) + builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=build_key_callback("connect_device", client_id, key_name))) - builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{key_name}")) + builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=build_key_callback("view_key", client_id, key_name))) builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL)) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) diff --git a/handlers/keys/key_mode/key_country_mode.py b/handlers/keys/key_mode/key_country_mode.py index 6897feec..971c1362 100644 --- a/handlers/keys/key_mode/key_country_mode.py +++ b/handlers/keys/key_mode/key_country_mode.py @@ -47,6 +47,7 @@ from handlers.buttons import ( SUPPORT, TV_BUTTON, ) +from handlers.keys.utils import build_key_callback, resolve_key from handlers.keys.operations import create_client_on_server from handlers.keys.operations.aggregated_links import make_aggregated_link from handlers.tariffs.tariff_display import ( @@ -258,7 +259,9 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any): await callback_query.answer("❌ Некорректные данные", show_alert=True) return - old_key_name = data[1] + old_key_ref = data[1] + key_obj = await resolve_key(session, callback_query.from_user.id, old_key_ref) + old_key_name = key_obj.email if key_obj else old_key_ref record = await get_key_details(session, old_key_name) if not record: await callback_query.answer("❌ Ключ не найден", show_alert=True) @@ -351,7 +354,12 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any): available_servers = [s["server_name"] for s in filtered_servers] else: builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}")) + builder.row( + InlineKeyboardButton( + text=BACK, + callback_data=build_key_callback("view_key", record.get("client_id"), old_key_name), + ) + ) await edit_or_send_message( target_message=callback_query.message, text="❌ Нет доступных стран для смены локации.", @@ -373,7 +381,12 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any): if not available_servers: builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}")) + builder.row( + InlineKeyboardButton( + text=BACK, + callback_data=build_key_callback("view_key", record.get("client_id"), old_key_name), + ) + ) await edit_or_send_message( target_message=callback_query.message, text="❌ Нет доступных стран для смены локации.", @@ -386,11 +399,16 @@ async def change_location_callback(callback_query: CallbackQuery, session: Any): for i in range(0, len(available_servers), 2): row_buttons = [] for country in available_servers[i : i + 2]: - callback_data = f"select_country|{country}|{ts}|{old_key_name}" + callback_data = f"select_country|{country}|{ts}|{old_key_ref}" row_buttons.append(InlineKeyboardButton(text=country, callback_data=callback_data)) builder.row(*row_buttons) - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}")) + builder.row( + InlineKeyboardButton( + text=BACK, + callback_data=build_key_callback("view_key", record.get("client_id"), old_key_name), + ) + ) await edit_or_send_message( target_message=callback_query.message, @@ -487,6 +505,8 @@ async def finalize_key_creation( old_key_details: dict[str, Any] | None = None if old_key_name: + key_obj = await resolve_key(session, tg_id, old_key_name) + old_key_name = key_obj.email if key_obj else old_key_name old_key_details = await get_key_details(session, old_key_name) if not old_key_details: await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.") @@ -769,7 +789,7 @@ async def finalize_key_creation( if panel_type == "remnawave" or is_full_remnawave: if is_vless: - builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}")) + builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=build_key_callback("connect_router", client_id, key_name))) else: if use_webapp and webapp_url: if open_in_browser: @@ -777,13 +797,23 @@ async def finalize_key_creation( else: builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=webapp_url))) if tv_button_enabled: - builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")) + builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=build_key_callback("connect_tv", client_id, key_name))) else: - builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}")) + builder.row( + InlineKeyboardButton( + text=CONNECT_DEVICE, + callback_data=build_key_callback("connect_device", client_id, key_name), + ) + ) else: - builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}")) + builder.row( + InlineKeyboardButton( + text=CONNECT_DEVICE, + callback_data=build_key_callback("connect_device", client_id, key_name), + ) + ) - builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{key_name}")) + builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=build_key_callback("view_key", client_id, key_name))) builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL)) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) diff --git a/handlers/keys/key_mode/key_discount_mode.py b/handlers/keys/key_mode/key_discount_mode.py index a6fa16f5..d732ab01 100644 --- a/handlers/keys/key_mode/key_discount_mode.py +++ b/handlers/keys/key_mode/key_discount_mode.py @@ -12,6 +12,7 @@ from core.bootstrap import NOTIFICATIONS_CONFIG from database import get_keys, get_tariffs, get_tariffs_for_cluster from database.models import Notification from handlers.buttons import MAIN_MENU, RENEW_KEY_NOTIFICATION +from handlers.keys.utils import build_key_callback from handlers.notifications.notify_kb import build_tariffs_keyboard from handlers.tariffs.buy.key_tariffs import select_tariff_plan from handlers.texts import DISCOUNT_TARIFF, DISCOUNT_TARIFF_MAX @@ -49,7 +50,12 @@ async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession): if keys and len(keys) > 0: builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=RENEW_KEY_NOTIFICATION, callback_data=f"renew_key|{keys[0].email}")) + builder.row( + InlineKeyboardButton( + text=RENEW_KEY_NOTIFICATION, + callback_data=build_key_callback("renew_key", keys[0].client_id, keys[0].email), + ) + ) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) expires_at = last_time + timedelta(hours=discount_active_hours) @@ -127,7 +133,12 @@ async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession): if keys and len(keys) > 0: builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=RENEW_KEY_NOTIFICATION, callback_data=f"renew_key|{keys[0].email}")) + builder.row( + InlineKeyboardButton( + text=RENEW_KEY_NOTIFICATION, + callback_data=build_key_callback("renew_key", keys[0].client_id, keys[0].email), + ) + ) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) expires_at = last_time + timedelta(hours=discount_active_hours) diff --git a/handlers/keys/key_renew.py b/handlers/keys/key_renew.py index f6ce7ddc..6e4a3b9e 100644 --- a/handlers/keys/key_renew.py +++ b/handlers/keys/key_renew.py @@ -56,7 +56,7 @@ from hooks.processors import ( ) from logger import logger -from .utils import add_tariff_button_generic, key_owned_by_user +from .utils import add_tariff_button_generic, build_key_callback, key_owned_by_user, resolve_key router = Router() @@ -79,7 +79,9 @@ def normalize_expiry_ms(raw_value: int | float | None) -> int: async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession): """Обрабатывает нажатие кнопки продления конкретного ключа.""" tg_id = callback_query.message.chat.id - key_name = callback_query.data.split("|")[1] + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + key_name = key_obj.email if key_obj else key_ref try: record = await get_key_details(session, key_name) @@ -103,7 +105,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo if now_utc < available_from_utc: dt_msk = available_from_utc.astimezone(moscow_tz).strftime("%d.%m.%Y %H:%M") kb = InlineKeyboardBuilder() - kb.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")) + kb.row(InlineKeyboardButton(text=BACK, callback_data=build_key_callback("view_key", client_id, key_name))) hook_commands = await process_process_callback_renew_key( callback_query=callback_query, state=state, session=session @@ -118,7 +120,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo ) return - await state.update_data(renew_key_name=key_name, renew_client_id=client_id) + await state.update_data(renew_key_name=key_name, renew_client_id=client_id, renew_key_ref=key_ref) logger.info(f"[RENEW] Получение тарифов для server_id={server_id}") @@ -218,7 +220,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo ) ) - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data=build_key_callback("view_key", client_id, key_name))) hook_builder = InlineKeyboardBuilder() hook_builder.attach(builder) @@ -372,7 +374,12 @@ async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMCont callback_prefix="renew_plan", ) - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"renew_key|{key_name}")) + builder.row( + InlineKeyboardButton( + text=BACK, + callback_data=build_key_callback("renew_key", data.get("renew_client_id"), key_name), + ) + ) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) hook_builder = InlineKeyboardBuilder() @@ -884,7 +891,7 @@ async def complete_key_renewal( await reset_key_current_limits_to_selected(session, effective_client_id) builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{email}")) + builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=build_key_callback("view_key", client_id, email))) hook_commands = await process_renewal_complete( chat_id=tg_id, admin=False, session=session, email=email, client_id=client_id ) diff --git a/handlers/keys/key_view.py b/handlers/keys/key_view.py index ae35eacc..a58d4b7f 100644 --- a/handlers/keys/key_view.py +++ b/handlers/keys/key_view.py @@ -22,7 +22,6 @@ from config import ( QRCODE, REMNAWAVE_WEBAPP, REMNAWAVE_WEBAPP_OPEN_IN_BROWSER, - TOGGLE_CLIENT, USE_COUNTRY_SELECTION, ) from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG @@ -43,14 +42,12 @@ from handlers.buttons import ( CHANGE_LOCATION, CONNECT_DEVICE, DELETE, - FREEZE, HWID_BUTTON, MAIN_MENU, QR, RENEW_KEY, ROUTER_BUTTON, TV_BUTTON, - UNFREEZE, ) from database import get_vless_enabled_batch from handlers.tariffs.tariff_display import GB, get_key_tariff_addons_state @@ -63,7 +60,7 @@ from handlers.texts import ( RENAME_KEY_PROMPT, key_message, ) -from handlers.keys.utils import key_owned_by_user +from handlers.keys.utils import build_key_callback, build_key_ref, key_owned_by_user, resolve_key from handlers.utils import ( edit_or_send_message, format_days, @@ -106,9 +103,9 @@ async def process_callback_or_message_view_keys( records = await get_keys(session, tg_id) if records and len(records) == 1: - key_name = records[0].email + key_ref = build_key_ref(records[0].client_id, records[0].email) image_path = os.path.join("img", "pic_view.jpg") - await render_key_info(target_message, session, key_name, image_path) + await render_key_info(target_message, session, key_ref, image_path) return inline_keyboard, response_message = await build_keys_response(records, session, page=page) @@ -186,7 +183,7 @@ async def build_keys_response(records: list[Key] | None, session: AsyncSession, key_button = InlineKeyboardButton( text=f"{icon} {key_display}", - callback_data=f"view_key|{email}", + callback_data=build_key_callback("view_key", client_id, email), ) rename_button = InlineKeyboardButton( text=ALIAS, @@ -292,24 +289,26 @@ async def handle_new_alias_input(message: Message, state: FSMContext, session: A @router.callback_query(F.data.startswith("view_key|")) async def process_callback_view_key(callback_query: CallbackQuery, session: AsyncSession): - key_name = callback_query.data.split("|")[1] - record = await get_key_details(session, key_name) + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + record = await get_key_details(session, key_obj.email) if key_obj else None if not key_owned_by_user(record, callback_query.from_user.id): await safe_answer_callback(callback_query, "Доступ запрещён.", show_alert=True) return image_path = os.path.join("img", "pic_view.jpg") - await render_key_info(callback_query.message, session, key_name, image_path) + await render_key_info(callback_query.message, session, key_ref, image_path) -async def build_key_view_payload(session: AsyncSession, key_name: str): +async def build_key_view_payload(session: AsyncSession, tg_id: int, key_ref_or_email: str): + key_obj = await resolve_key(session, tg_id, key_ref_or_email) + key_name = key_obj.email if key_obj else key_ref_or_email record = await get_key_details(session, key_name) if not record: builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) return "Информация о подписке не найдена.", builder.as_markup(), False - db_key_result = await session.execute(select(Key).where(Key.email == key_name)) - db_key: Key | None = db_key_result.scalar_one_or_none() + db_key = key_obj is_frozen = record["is_frozen"] client_id = record.get("client_id") @@ -318,7 +317,6 @@ async def build_key_view_payload(session: AsyncSession, key_name: str): builder = InlineKeyboardBuilder() if is_frozen: - builder.row(InlineKeyboardButton(text=UNFREEZE, callback_data=f"unfreeze_subscription|{key_name}")) builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) return FROZEN_SUBSCRIPTION_MSG, builder.as_markup(), True @@ -420,22 +418,47 @@ async def build_key_view_payload(session: AsyncSession, key_name: str): if is_full_remnawave and final_link and use_webapp and not happ_cryptolink_enabled: if vless_enabled: - builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}")) + builder.row( + InlineKeyboardButton( + text=ROUTER_BUTTON, + callback_data=build_key_callback("connect_router", client_id, key_name), + ) + ) elif open_in_browser: builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, url=final_link)) if tv_button_enabled: - builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}")) + builder.row( + InlineKeyboardButton( + text=TV_BUTTON, + callback_data=build_key_callback("connect_tv", client_id, key_name), + ) + ) else: builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link))) if tv_button_enabled: - builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}")) + builder.row( + InlineKeyboardButton( + text=TV_BUTTON, + callback_data=build_key_callback("connect_tv", client_id, key_name), + ) + ) else: if vless_enabled: - builder.row(InlineKeyboardButton(text=ROUTER_BUTTON, callback_data=f"connect_router|{key_name}")) + builder.row( + InlineKeyboardButton( + text=ROUTER_BUTTON, + callback_data=build_key_callback("connect_router", client_id, key_name), + ) + ) else: - builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}")) + builder.row( + InlineKeyboardButton( + text=CONNECT_DEVICE, + callback_data=build_key_callback("connect_device", client_id, key_name), + ) + ) - builder.row(InlineKeyboardButton(text=RENEW_KEY, callback_data=f"renew_key|{key_name}")) + builder.row(InlineKeyboardButton(text=RENEW_KEY, callback_data=build_key_callback("renew_key", client_id, key_name))) if is_tariff_configurable and (addons_devices_enabled or addons_traffic_enabled): if addons_devices_enabled and addons_traffic_enabled: @@ -444,27 +467,24 @@ async def build_key_view_payload(session: AsyncSession, key_name: str): addons_text = ADDONS_BUTTON_DEVICES else: addons_text = ADDONS_BUTTON_TRAFFIC - builder.row(InlineKeyboardButton(text=addons_text, callback_data=f"key_addons|{key_name}")) + builder.row(InlineKeyboardButton(text=addons_text, callback_data=build_key_callback("key_addons", client_id, key_name))) hwid_reset_enabled = bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)) qrcode_enabled = bool(BUTTONS_CONFIG.get("QRCODE_BUTTON_ENABLE", QRCODE)) delete_key_enabled = bool(BUTTONS_CONFIG.get("DELETE_KEY_BUTTON_ENABLE", ENABLE_DELETE_KEY_BUTTON)) - toggle_client_enabled = bool(BUTTONS_CONFIG.get("TOGGLE_CLIENT_BUTTON_ENABLE", TOGGLE_CLIENT)) - if hwid_reset_enabled and hwid_count > 0: - builder.row(InlineKeyboardButton(text=HWID_BUTTON, callback_data=f"reset_hwid|{key_name}")) + builder.row(InlineKeyboardButton(text=HWID_BUTTON, callback_data=build_key_callback("reset_hwid", client_id, key_name))) if qrcode_enabled: - builder.row(InlineKeyboardButton(text=QR, callback_data=f"show_qr|{key_name}")) + builder.row(InlineKeyboardButton(text=QR, callback_data=build_key_callback("show_qr", client_id, key_name))) if delete_key_enabled: - builder.row(InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}")) + builder.row(InlineKeyboardButton(text=DELETE, callback_data=build_key_callback("delete_key", client_id, key_name))) if country_selection_enabled: - builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}")) - - if toggle_client_enabled: - builder.row(InlineKeyboardButton(text=FREEZE, callback_data=f"freeze_subscription|{key_name}")) + builder.row( + InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=build_key_callback("change_location", client_id, key_name)) + ) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) @@ -475,12 +495,12 @@ async def build_key_view_payload(session: AsyncSession, key_name: str): async def build_key_view_message(session: AsyncSession, email: str): - text, reply_markup, _ = await build_key_view_payload(session, email) + text, reply_markup, _ = await build_key_view_payload(session, 0, email) return text, reply_markup -async def render_key_info(message: Message, session: AsyncSession, key_name: str, image_path: str): - text, reply_markup, _ = await build_key_view_payload(session, key_name) +async def render_key_info(message: Message, session: AsyncSession, key_ref_or_email: str, image_path: str): + text, reply_markup, _ = await build_key_view_payload(session, message.chat.id, key_ref_or_email) await edit_or_send_message( target_message=message, text=text, @@ -491,8 +511,9 @@ async def render_key_info(message: Message, session: AsyncSession, key_name: str @router.callback_query(F.data.startswith("reset_hwid|")) async def handle_reset_hwid(callback_query: CallbackQuery, session: AsyncSession): - key_name = callback_query.data.split("|")[1] - + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + key_name = key_obj.email if key_obj else key_ref record = await get_key_details(session, key_name) if not record: await safe_answer_callback(callback_query, "❌ Ключ не найден.", show_alert=True) @@ -562,4 +583,4 @@ async def handle_reset_hwid(callback_query: CallbackQuery, session: AsyncSession return image_path = os.path.join("img", "pic_view.jpg") - await render_key_info(callback_query.message, session, key_name, image_path) + await render_key_info(callback_query.message, session, key_ref, image_path) diff --git a/handlers/keys/keys.py b/handlers/keys/keys.py index 5f108ec9..9a278ee2 100644 --- a/handlers/keys/keys.py +++ b/handlers/keys/keys.py @@ -7,7 +7,7 @@ from database import delete_key, get_key_details from handlers.buttons import APPLY, BACK, CANCEL from handlers.keys.key_view import process_callback_view_key from handlers.keys.operations import delete_key_from_cluster, update_subscription -from handlers.keys.utils import key_owned_by_user +from handlers.keys.utils import build_key_callback, key_owned_by_user, resolve_key from handlers.texts import DELETE_KEY_CONFIRM_MSG, KEY_DELETED_MSG_SIMPLE from handlers.utils import edit_or_send_message, handle_error from middlewares.session import release_session_early @@ -20,7 +20,9 @@ router = Router() @router.callback_query(F.data.startswith("update_subscription|")) async def process_callback_update_subscription(callback_query: CallbackQuery, session: AsyncSession): tg_id = callback_query.message.chat.id - email = callback_query.data.split("|")[1] + key_ref = callback_query.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + email = key_obj.email if key_obj else key_ref try: record = await get_key_details(session, email) @@ -42,15 +44,17 @@ async def process_callback_update_subscription(callback_query: CallbackQuery, se @router.callback_query(F.data.startswith("delete_key|")) async def process_callback_delete_key(callback_query: CallbackQuery, session: AsyncSession): - key_identifier = callback_query.data.split("|")[1] + key_ref = callback_query.data.split("|", 1)[1] try: + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + key_identifier = key_obj.email if key_obj else key_ref record = await get_key_details(session, key_identifier) if not key_owned_by_user(record, callback_query.from_user.id): await callback_query.answer("Доступ запрещён.", show_alert=True) return confirmation_keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ - [types.InlineKeyboardButton(text=APPLY, callback_data=f"confirm_delete|{key_identifier}")], + [types.InlineKeyboardButton(text=APPLY, callback_data=build_key_callback("confirm_delete", record.get("client_id"), key_identifier))], [types.InlineKeyboardButton(text=CANCEL, callback_data="view_keys")], ] ) @@ -68,8 +72,10 @@ async def process_callback_delete_key(callback_query: CallbackQuery, session: As @router.callback_query(F.data.startswith("confirm_delete|")) async def process_callback_confirm_delete(callback_query: CallbackQuery, session: AsyncSession): - email = callback_query.data.split("|")[1] + key_ref = callback_query.data.split("|", 1)[1] try: + key_obj = await resolve_key(session, callback_query.from_user.id, key_ref) + email = key_obj.email if key_obj else key_ref record = await get_key_details(session, email) if not key_owned_by_user(record, callback_query.from_user.id): await callback_query.answer("Доступ запрещён.", show_alert=True) diff --git a/handlers/keys/subscriptions.py b/handlers/keys/subscriptions.py index 73174e92..d3d5d572 100644 --- a/handlers/keys/subscriptions.py +++ b/handlers/keys/subscriptions.py @@ -213,7 +213,6 @@ def prepare_headers( "announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"), "profile-web-page-url": f"https://t.me/{USERNAME_BOT}", "subscription-userinfo": subscription_userinfo, - # "routing": "happ://routing/onadd/...", } elif "Hiddify" in user_agent: parts = subscription_info.split(" - ")[0].split(": ") diff --git a/handlers/keys/utils.py b/handlers/keys/utils.py index 198df76b..f97d6e18 100644 --- a/handlers/keys/utils.py +++ b/handlers/keys/utils.py @@ -1,9 +1,13 @@ +import hashlib + from typing import Any from aiogram.types import InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder from sqlalchemy.ext.asyncio import AsyncSession +from database import get_key_by_client_id, get_key_by_email, get_keys +from database.models import Key from handlers.payments.currency_rates import format_for_user @@ -12,6 +16,36 @@ def key_owned_by_user(record: dict | None, user_id: int) -> bool: return record is not None and record.get("tg_id") == user_id +def build_key_ref(client_id: str | None, email: str | None = None) -> str: + source = str(client_id or email or "") + return hashlib.blake2s(source.encode(), digest_size=6).hexdigest() + + +def build_key_callback(prefix: str, client_id: str | None, email: str | None = None) -> str: + return f"{prefix}|{build_key_ref(client_id, email)}" + + +async def resolve_key(session: AsyncSession, tg_id: int, key_ref: str | int | None) -> Key | None: + if key_ref is None: + return None + + key_ref_str = str(key_ref) + + key_obj = await get_key_by_email(session, key_ref_str, tg_id) + if key_obj: + return key_obj + + key_obj = await get_key_by_client_id(session, key_ref_str, tg_id) + if key_obj: + return key_obj + + for candidate in await get_keys(session, tg_id): + if build_key_ref(candidate.client_id, candidate.email) == key_ref_str: + return await get_key_by_client_id(session, candidate.client_id, tg_id) + + return None + + async def add_tariff_button_generic( builder: InlineKeyboardBuilder, tariff: dict[str, Any], diff --git a/handlers/notifications/general_notifications.py b/handlers/notifications/general_notifications.py index 635926e5..2dffe615 100644 --- a/handlers/notifications/general_notifications.py +++ b/handlers/notifications/general_notifications.py @@ -215,7 +215,7 @@ async def send_expiry_warning(ctx: NotificationContext, key, hours_left: int, ph tariff_details=expiry_data["tariff_details"], ) - keyboard = build_notification_kb(email) + keyboard = build_notification_kb(email, getattr(key, "client_id", None)) return await send_notification(ctx.bot, tg_id, photo, message_text, keyboard) @@ -233,7 +233,7 @@ async def send_cannot_renew(ctx: NotificationContext, key, photo: str) -> bool: tariff_details=expiry_data["tariff_details"], ) - keyboard = build_change_tariff_kb(email) + keyboard = build_change_tariff_kb(email, getattr(key, "client_id", None)) return await send_notification(ctx.bot, tg_id, photo, message_text, keyboard) @@ -254,7 +254,7 @@ async def send_expired_notification(ctx: NotificationContext, key, delay_minutes else: message_text = KEY_EXPIRED_NO_DELAY_MSG.format(email=email) - keyboard = build_notification_kb(email) + keyboard = build_notification_kb(email, getattr(key, "client_id", None)) return await send_notification(ctx.bot, tg_id, "notify_expired.jpg", message_text, keyboard) @@ -498,7 +498,7 @@ async def notify_expiring_keys( tariff_name=expiry_data["tariff_name"], tariff_details=expiry_data["tariff_details"], ) - keyboard = build_notification_kb(email) + keyboard = build_notification_kb(email, getattr(key, "client_id", None)) messages.append({ "tg_id": tg_id, "text": notification_text, diff --git a/handlers/notifications/notify_kb.py b/handlers/notifications/notify_kb.py index 20e61e12..7113a39e 100644 --- a/handlers/notifications/notify_kb.py +++ b/handlers/notifications/notify_kb.py @@ -1,9 +1,10 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from handlers.buttons import CHANGE_TARIFF, DISCOUNT_TARIFF, MAIN_MENU, MAX_DISCOUNT_TARIFF, RENEW_KEY_NOTIFICATION +from handlers.keys.utils import build_key_callback -def build_notification_kb(email: str) -> InlineKeyboardMarkup: +def build_notification_kb(email: str, client_id: str | None = None) -> InlineKeyboardMarkup: """ Формирует inline-клавиатуру для уведомлений. Кнопки: "🔄 Продлить VPN" (callback_data содержит email) и "👤 Личный кабинет". @@ -11,13 +12,13 @@ def build_notification_kb(email: str) -> InlineKeyboardMarkup: from aiogram.utils.keyboard import InlineKeyboardBuilder builder = InlineKeyboardBuilder() - builder.button(text=RENEW_KEY_NOTIFICATION, callback_data=f"renew_key|{email}") + builder.button(text=RENEW_KEY_NOTIFICATION, callback_data=build_key_callback("renew_key", client_id, email)) builder.button(text=MAIN_MENU, callback_data="profile") builder.adjust(1) return builder.as_markup() -def build_change_tariff_kb(email: str) -> InlineKeyboardMarkup: +def build_change_tariff_kb(email: str, client_id: str | None = None) -> InlineKeyboardMarkup: """ Формирует inline-клавиатуру для смены тарифа. Кнопки: "🔄 Сменить тариф" (callback_data содержит email) и "👤 Личный кабинет". @@ -25,7 +26,7 @@ def build_change_tariff_kb(email: str) -> InlineKeyboardMarkup: from aiogram.utils.keyboard import InlineKeyboardBuilder builder = InlineKeyboardBuilder() - builder.button(text=CHANGE_TARIFF, callback_data=f"renew_key|{email}") + builder.button(text=CHANGE_TARIFF, callback_data=build_key_callback("renew_key", client_id, email)) builder.button(text=MAIN_MENU, callback_data="profile") builder.adjust(1) return builder.as_markup() diff --git a/handlers/notifications/special_notifications.py b/handlers/notifications/special_notifications.py index e476b0c4..79fcd7af 100644 --- a/handlers/notifications/special_notifications.py +++ b/handlers/notifications/special_notifications.py @@ -21,6 +21,7 @@ from database import add_notification, check_notifications_bulk from database.models import Key, User from database.tariffs import get_tariffs from handlers.buttons import CONNECT_DEVICE, MAIN_MENU, SUPPORT, TRIAL_BONUS +from handlers.keys.utils import build_key_callback from handlers.keys.operations import get_user_traffic from handlers.notifications.notify_utils import send_messages_with_limit from handlers.texts import ( @@ -209,10 +210,20 @@ async def notify_users_no_traffic(bot: Bot, session: AsyncSession, current_time: else: builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link))) else: - builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{email}")) + builder.row( + InlineKeyboardButton( + text=CONNECT_DEVICE, + callback_data=build_key_callback("connect_device", key.client_id, email), + ) + ) except Exception as error: logger.error(f"Ошибка при определении типа панели для {email}: {error}") - builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{email}")) + builder.row( + InlineKeyboardButton( + text=CONNECT_DEVICE, + callback_data=build_key_callback("connect_device", key.client_id, email), + ) + ) builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL)) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) diff --git a/handlers/tariffs/addons/key_addons_main.py b/handlers/tariffs/addons/key_addons_main.py index 2aba344f..ecef8ab4 100644 --- a/handlers/tariffs/addons/key_addons_main.py +++ b/handlers/tariffs/addons/key_addons_main.py @@ -37,6 +37,7 @@ from hooks.processors import process_addon_purchase_complete, process_addons_men from logger import logger from ..buy.key_tariffs import calculate_config_price +from ...keys.utils import build_key_callback, resolve_key from .utils import ( KeyAddonConfigState, build_addons_screen_text, @@ -295,7 +296,12 @@ async def render_addons_screen(callback: CallbackQuery, state: FSMContext, sessi ) ) - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}")) + builder.row( + InlineKeyboardButton( + text=BACK, + callback_data=build_key_callback("view_key", data.get("addon_key_client_id"), email), + ) + ) module_buttons = await process_addons_menu(email=email, session=session) builder = insert_hook_buttons(builder, module_buttons) @@ -310,7 +316,9 @@ async def render_addons_screen(callback: CallbackQuery, state: FSMContext, sessi @router.callback_query(F.data.startswith("key_addons|")) async def start_key_addons(callback: CallbackQuery, state: FSMContext, session: AsyncSession): - email = callback.data.split("|")[1] + key_ref = callback.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback.from_user.id, key_ref) + email = key_obj.email if key_obj else key_ref logger.debug(f"[ADDONS] start_key_addons: tg_id={callback.from_user.id} email={email}") record = await get_key_details(session, email) @@ -428,6 +436,7 @@ async def start_key_addons(callback: CallbackQuery, state: FSMContext, session: await state.update_data( addon_key_email=email, + addon_key_client_id=record.get("client_id"), addon_tariff_id=int(tariff_id), addon_tariff_config=cfg_for_state, addon_current_device_limit=current_devices, @@ -581,7 +590,12 @@ async def handle_addons_downgrade(callback: CallbackQuery, state: FSMContext, se callback_data="key_addons_downgrade_apply", ) ) - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"key_addons|{email}")) + builder.row( + InlineKeyboardButton( + text=BACK, + callback_data=build_key_callback("key_addons", data.get("addon_key_client_id"), email), + ) + ) await edit_or_send_message( target_message=callback.message, diff --git a/handlers/tariffs/addons/key_addons_pack.py b/handlers/tariffs/addons/key_addons_pack.py index d24bba4e..9b86842b 100644 --- a/handlers/tariffs/addons/key_addons_pack.py +++ b/handlers/tariffs/addons/key_addons_pack.py @@ -37,6 +37,7 @@ from hooks.processors import process_addon_purchase_complete, process_addons_men from logger import logger from ..buy.key_tariffs import calculate_config_price +from ...keys.utils import build_key_callback, resolve_key from .utils import ( KeyAddonConfigState, build_addons_pack_screen_text, @@ -373,7 +374,12 @@ async def render_addons_screen(callback: CallbackQuery, state: FSMContext, sessi callback_data="key_addons_confirm", ) ) - builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}")) + builder.row( + InlineKeyboardButton( + text=BACK, + callback_data=build_key_callback("view_key", data.get("addon_key_client_id"), email), + ) + ) module_buttons = await process_addons_menu(email=email, session=session) builder = insert_hook_buttons(builder, module_buttons) @@ -388,7 +394,9 @@ async def render_addons_screen(callback: CallbackQuery, state: FSMContext, sessi @router.callback_query(F.data.startswith("key_addons|")) async def start_key_addons(callback: CallbackQuery, state: FSMContext, session: AsyncSession): - email = callback.data.split("|")[1] + key_ref = callback.data.split("|", 1)[1] + key_obj = await resolve_key(session, callback.from_user.id, key_ref) + email = key_obj.email if key_obj else key_ref logger.debug(f"[ADDONS] PACK_MODE start_key_addons: tg_id={callback.from_user.id} email={email}") record = await get_key_details(session, email) @@ -546,6 +554,7 @@ async def start_key_addons(callback: CallbackQuery, state: FSMContext, session: await state.update_data( addon_key_email=email, + addon_key_client_id=record.get("client_id"), addon_tariff_id=int(tariff_id), addon_tariff_config=cfg_for_state, addon_current_device_limit=current_devices, diff --git a/utils/backup.py b/utils/backup.py index 585f576e..b25a7c5a 100644 --- a/utils/backup.py +++ b/utils/backup.py @@ -42,9 +42,6 @@ async def backup_database() -> Exception | None: """ from core.executor import run_io - # Создаём бэкап в пуле потоков (run_io), а не процессов (run_cpu), чтобы файл - # создавался в том же процессе, что и отправка — иначе путь может быть недоступен - # (воркер уведомлений и воркер пула процессов могут иметь разный cwd/окружение). if BACKUP_CREATE_ARCHIVE: if not any([BACKUP_INCLUDE_DB, BACKUP_INCLUDE_CONFIG, BACKUP_INCLUDE_TEXTS, BACKUP_INCLUDE_IMG]): backup_file_path, exception = await run_io(_create_database_backup) diff --git a/utils/versioning.py b/utils/versioning.py index 7fe3bc1a..91fafc52 100644 --- a/utils/versioning.py +++ b/utils/versioning.py @@ -92,4 +92,4 @@ def get_git_commit_number() -> str: def get_version() -> str: - return f"a02032612 {get_git_commit_number()}" + return f"a02031919 {get_git_commit_number()}"