From 174f9b851715da10041aeb53f60ba4abfb63b2af Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 15 Dec 2025 07:18:45 +0300 Subject: [PATCH 1/7] Add admin web panel miniapp toggle --- app/config.py | 9 +++++++ app/keyboards/inline.py | 33 ++++++++++++++++++++++--- app/localization/locales/en.json | 1 + app/localization/locales/ru.json | 1 + app/localization/locales/ua.json | 1 + app/localization/locales/zh.json | 1 + app/services/system_settings_service.py | 13 ++++++++++ 7 files changed, 55 insertions(+), 4 deletions(-) diff --git a/app/config.py b/app/config.py index 9a3ee4e9..c5bddd00 100644 --- a/app/config.py +++ b/app/config.py @@ -330,6 +330,8 @@ class Settings(BaseSettings): MAIN_MENU_MODE: str = "default" CONNECT_BUTTON_MODE: str = "guide" + ADMIN_WEB_PANEL_ENABLED: bool = True + ADMIN_WEB_PANEL_URL: str = "https://bedolagam.ru" MINIAPP_CUSTOM_URL: str = "" MINIAPP_STATIC_PATH: str = "miniapp" MINIAPP_PURCHASE_URL: str = "" @@ -856,6 +858,13 @@ class Settings(BaseSettings): def is_text_main_menu_mode(self) -> bool: return self.get_main_menu_mode() == "text" + def get_admin_web_panel_url(self) -> Optional[str]: + value = (getattr(self, "ADMIN_WEB_PANEL_URL", None) or "").strip() + return value or None + + def is_admin_web_panel_enabled(self) -> bool: + return bool(self.ADMIN_WEB_PANEL_ENABLED and self.get_admin_web_panel_url()) + def get_main_menu_miniapp_url(self) -> Optional[str]: for candidate in [self.MINIAPP_CUSTOM_URL, self.MINIAPP_PURCHASE_URL]: value = (candidate or "").strip() diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index a01189c3..8d5bba8c 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -270,9 +270,21 @@ def _build_text_main_menu_keyboard( ]) if is_admin: - keyboard_rows.append([ + admin_buttons = [ InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data="admin_panel") - ]) + ] + + if settings.is_admin_web_panel_enabled(): + admin_web_url = settings.get_admin_web_panel_url() + if admin_web_url: + admin_buttons.append( + InlineKeyboardButton( + text=texts.t("MENU_ADMIN_WEB", "🌐 Вебадминка"), + web_app=types.WebAppInfo(url=admin_web_url), + ) + ) + + keyboard_rows.append(admin_buttons) elif is_moderator: keyboard_rows.append([ InlineKeyboardButton(text="🧑‍⚖️ Модерация", callback_data="moderator_panel") @@ -471,9 +483,22 @@ def get_main_menu_keyboard( if is_admin: if settings.DEBUG: print("DEBUG KEYBOARD: Админ кнопка ДОБАВЛЕНА!") - keyboard.append([ + + admin_buttons = [ InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data="admin_panel") - ]) + ] + + if settings.is_admin_web_panel_enabled(): + admin_web_url = settings.get_admin_web_panel_url() + if admin_web_url: + admin_buttons.append( + InlineKeyboardButton( + text=texts.t("MENU_ADMIN_WEB", "🌐 Вебадминка"), + web_app=types.WebAppInfo(url=admin_web_url), + ) + ) + + keyboard.append(admin_buttons) else: if settings.DEBUG: print("DEBUG KEYBOARD: Админ кнопка НЕ добавлена") diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 02cf3d0f..13789b82 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1038,6 +1038,7 @@ "MANAGE_DEVICES_BUTTON": "🔧 Manage devices", "MARK_AS_ANSWERED": "✅ Mark as answered", "MENU_ADMIN": "⚙️ Admin panel", + "MENU_ADMIN_WEB": "🌐 Web admin", "MENU_BALANCE": "💰 Balance", "MENU_BUY_SUBSCRIPTION": "💎 Buy subscription", "MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index e1d2b5cb..0217a618 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1050,6 +1050,7 @@ "MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами", "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", "MENU_ADMIN": "⚙️ Админ-панель", + "MENU_ADMIN_WEB": "🌐 Вебадминка", "MENU_BALANCE": "💰 Баланс", "MENU_BUY_SUBSCRIPTION": "💎 Купить подписку", "MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 69139498..418ab092 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -983,6 +983,7 @@ "MANAGE_DEVICES_BUTTON": "🔧 Керування пристроями", "MARK_AS_ANSWERED": "✅ Позначити як такий, що отримав відповідь", "MENU_ADMIN": "⚙️ Адмін-панель", + "MENU_ADMIN_WEB": "🌐 Веб-адмінка", "MENU_BALANCE": "💰 Баланс", "MENU_BUY_SUBSCRIPTION": "💎 Купити підписку", "MENU_EXTEND_SUBSCRIPTION": "⏰ Продовжити підписку", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index a107bf81..49eafd94 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -982,6 +982,7 @@ "MANAGE_DEVICES_BUTTON":"🔧设备管理", "MARK_AS_ANSWERED":"✅标记为已回复", "MENU_ADMIN":"⚙️管理面板", +"MENU_ADMIN_WEB":"🌐 Web admin", "MENU_BALANCE":"💰余额", "MENU_BUY_SUBSCRIPTION":"💎购买订阅", "MENU_EXTEND_SUBSCRIPTION":"⏰延长订阅", diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 7b78034a..861378eb 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -320,6 +320,7 @@ class BotConfigurationService: "HAPP_": "HAPP", "SKIP_": "SKIP", "MINIAPP_": "MINIAPP", + "ADMIN_WEB_PANEL_": "EXTERNAL_ADMIN", "MONITORING_": "MONITORING", "NOTIFICATION_": "NOTIFICATIONS", "SERVER_STATUS": "SERVER_STATUS", @@ -458,6 +459,18 @@ class BotConfigurationService: "format": "Выберите пакет трафика.", "example": "Безлимит", }, + "ADMIN_WEB_PANEL_ENABLED": { + "description": "Показывает кнопку веб-админки в главном меню администраторов.", + "format": "Булево значение.", + "example": "true", + "warning": "Кнопка доступна только администраторам и требует валидного URL.", + }, + "ADMIN_WEB_PANEL_URL": { + "description": "Ссылка для открытия веб-админки в Telegram Mini App.", + "format": "Полный URL с https://", + "example": "https://bedolagam.ru", + "warning": "URL должен быть доступен из Telegram, иначе кнопка будет скрыта.", + }, "SIMPLE_SUBSCRIPTION_SQUAD_UUID": { "description": ( "Привязка быстрой подписки к конкретному скваду. " From fc955bd3b247f6c05fd21911df26c5bb187a8311 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 15 Dec 2025 07:24:41 +0300 Subject: [PATCH 2/7] Revert "Add admin web panel miniapp toggle" --- app/config.py | 9 ------- app/keyboards/inline.py | 33 +++---------------------- app/localization/locales/en.json | 1 - app/localization/locales/ru.json | 1 - app/localization/locales/ua.json | 1 - app/localization/locales/zh.json | 1 - app/services/system_settings_service.py | 13 ---------- 7 files changed, 4 insertions(+), 55 deletions(-) diff --git a/app/config.py b/app/config.py index c5bddd00..9a3ee4e9 100644 --- a/app/config.py +++ b/app/config.py @@ -330,8 +330,6 @@ class Settings(BaseSettings): MAIN_MENU_MODE: str = "default" CONNECT_BUTTON_MODE: str = "guide" - ADMIN_WEB_PANEL_ENABLED: bool = True - ADMIN_WEB_PANEL_URL: str = "https://bedolagam.ru" MINIAPP_CUSTOM_URL: str = "" MINIAPP_STATIC_PATH: str = "miniapp" MINIAPP_PURCHASE_URL: str = "" @@ -858,13 +856,6 @@ class Settings(BaseSettings): def is_text_main_menu_mode(self) -> bool: return self.get_main_menu_mode() == "text" - def get_admin_web_panel_url(self) -> Optional[str]: - value = (getattr(self, "ADMIN_WEB_PANEL_URL", None) or "").strip() - return value or None - - def is_admin_web_panel_enabled(self) -> bool: - return bool(self.ADMIN_WEB_PANEL_ENABLED and self.get_admin_web_panel_url()) - def get_main_menu_miniapp_url(self) -> Optional[str]: for candidate in [self.MINIAPP_CUSTOM_URL, self.MINIAPP_PURCHASE_URL]: value = (candidate or "").strip() diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 8d5bba8c..a01189c3 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -270,21 +270,9 @@ def _build_text_main_menu_keyboard( ]) if is_admin: - admin_buttons = [ + keyboard_rows.append([ InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data="admin_panel") - ] - - if settings.is_admin_web_panel_enabled(): - admin_web_url = settings.get_admin_web_panel_url() - if admin_web_url: - admin_buttons.append( - InlineKeyboardButton( - text=texts.t("MENU_ADMIN_WEB", "🌐 Вебадминка"), - web_app=types.WebAppInfo(url=admin_web_url), - ) - ) - - keyboard_rows.append(admin_buttons) + ]) elif is_moderator: keyboard_rows.append([ InlineKeyboardButton(text="🧑‍⚖️ Модерация", callback_data="moderator_panel") @@ -483,22 +471,9 @@ def get_main_menu_keyboard( if is_admin: if settings.DEBUG: print("DEBUG KEYBOARD: Админ кнопка ДОБАВЛЕНА!") - - admin_buttons = [ + keyboard.append([ InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data="admin_panel") - ] - - if settings.is_admin_web_panel_enabled(): - admin_web_url = settings.get_admin_web_panel_url() - if admin_web_url: - admin_buttons.append( - InlineKeyboardButton( - text=texts.t("MENU_ADMIN_WEB", "🌐 Вебадминка"), - web_app=types.WebAppInfo(url=admin_web_url), - ) - ) - - keyboard.append(admin_buttons) + ]) else: if settings.DEBUG: print("DEBUG KEYBOARD: Админ кнопка НЕ добавлена") diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 13789b82..02cf3d0f 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1038,7 +1038,6 @@ "MANAGE_DEVICES_BUTTON": "🔧 Manage devices", "MARK_AS_ANSWERED": "✅ Mark as answered", "MENU_ADMIN": "⚙️ Admin panel", - "MENU_ADMIN_WEB": "🌐 Web admin", "MENU_BALANCE": "💰 Balance", "MENU_BUY_SUBSCRIPTION": "💎 Buy subscription", "MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 0217a618..e1d2b5cb 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1050,7 +1050,6 @@ "MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами", "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", "MENU_ADMIN": "⚙️ Админ-панель", - "MENU_ADMIN_WEB": "🌐 Вебадминка", "MENU_BALANCE": "💰 Баланс", "MENU_BUY_SUBSCRIPTION": "💎 Купить подписку", "MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 418ab092..69139498 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -983,7 +983,6 @@ "MANAGE_DEVICES_BUTTON": "🔧 Керування пристроями", "MARK_AS_ANSWERED": "✅ Позначити як такий, що отримав відповідь", "MENU_ADMIN": "⚙️ Адмін-панель", - "MENU_ADMIN_WEB": "🌐 Веб-адмінка", "MENU_BALANCE": "💰 Баланс", "MENU_BUY_SUBSCRIPTION": "💎 Купити підписку", "MENU_EXTEND_SUBSCRIPTION": "⏰ Продовжити підписку", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 49eafd94..a107bf81 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -982,7 +982,6 @@ "MANAGE_DEVICES_BUTTON":"🔧设备管理", "MARK_AS_ANSWERED":"✅标记为已回复", "MENU_ADMIN":"⚙️管理面板", -"MENU_ADMIN_WEB":"🌐 Web admin", "MENU_BALANCE":"💰余额", "MENU_BUY_SUBSCRIPTION":"💎购买订阅", "MENU_EXTEND_SUBSCRIPTION":"⏰延长订阅", diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 861378eb..7b78034a 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -320,7 +320,6 @@ class BotConfigurationService: "HAPP_": "HAPP", "SKIP_": "SKIP", "MINIAPP_": "MINIAPP", - "ADMIN_WEB_PANEL_": "EXTERNAL_ADMIN", "MONITORING_": "MONITORING", "NOTIFICATION_": "NOTIFICATIONS", "SERVER_STATUS": "SERVER_STATUS", @@ -459,18 +458,6 @@ class BotConfigurationService: "format": "Выберите пакет трафика.", "example": "Безлимит", }, - "ADMIN_WEB_PANEL_ENABLED": { - "description": "Показывает кнопку веб-админки в главном меню администраторов.", - "format": "Булево значение.", - "example": "true", - "warning": "Кнопка доступна только администраторам и требует валидного URL.", - }, - "ADMIN_WEB_PANEL_URL": { - "description": "Ссылка для открытия веб-админки в Telegram Mini App.", - "format": "Полный URL с https://", - "example": "https://bedolagam.ru", - "warning": "URL должен быть доступен из Telegram, иначе кнопка будет скрыта.", - }, "SIMPLE_SUBSCRIPTION_SQUAD_UUID": { "description": ( "Привязка быстрой подписки к конкретному скваду. " From 162e7da3502c4a3faef3a2ae225ddda7e264aa84 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 15 Dec 2025 10:40:59 +0300 Subject: [PATCH 3/7] Add cooldown for Happ device reset links --- .env.example | 6 + app/config.py | 52 +++++++- app/database/models.py | 4 +- app/database/universal_migration.py | 37 ++++++ app/handlers/admin/users.py | 10 +- app/handlers/subscription/devices.py | 55 ++++++++ app/localization/locales/en.json | 1 + app/localization/locales/ru.json | 1 + app/localization/locales/ua.json | 1 + app/localization/locales/zh.json | 1 + app/services/remnawave_service.py | 117 ++++++++++++++++++ app/services/subscription_service.py | 64 +++++++++- app/utils/happ_cryptolink_utils.py | 80 ++++++++++++ ...6c5c60dba2e_add_devices_reset_timestamp.py | 21 ++++ 14 files changed, 445 insertions(+), 5 deletions(-) create mode 100644 app/utils/happ_cryptolink_utils.py create mode 100644 migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py diff --git a/.env.example b/.env.example index 0fc1e422..ad38c3da 100644 --- a/.env.example +++ b/.env.example @@ -396,6 +396,12 @@ MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подкл # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false +HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED=false +HAPP_CRYPTOLINK_PROVIDER_CODE= +HAPP_CRYPTOLINK_AUTH_KEY= +HAPP_CRYPTOLINK_INSTALL_LIMIT=0 +HAPP_CRYPTOLINK_API_BASE_URL=https://api.happ-proxy.com +HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES=0 HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_MACOS= diff --git a/app/config.py b/app/config.py index 9a3ee4e9..438a84ed 100644 --- a/app/config.py +++ b/app/config.py @@ -6,7 +6,7 @@ import os import re import html from collections import defaultdict -from datetime import time +from datetime import time, timedelta from typing import Dict, List, Optional, Union from urllib.parse import urlparse from zoneinfo import ZoneInfo @@ -339,6 +339,12 @@ class Settings(BaseSettings): MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None + HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED: bool = False + HAPP_CRYPTOLINK_PROVIDER_CODE: Optional[str] = None + HAPP_CRYPTOLINK_AUTH_KEY: Optional[str] = None + HAPP_CRYPTOLINK_INSTALL_LIMIT: int = 0 + HAPP_CRYPTOLINK_API_BASE_URL: str = "https://api.happ-proxy.com" + HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES: int = 0 HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None @@ -1188,6 +1194,50 @@ class Settings(BaseSettings): def is_happ_download_button_enabled(self) -> bool: return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED + def is_happ_cryptolink_limited_links_enabled(self) -> bool: + if not self.is_happ_cryptolink_mode(): + return False + + if not self.HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED: + return False + + provider_code, auth_key = self.get_happ_cryptolink_credentials() + return bool(provider_code and auth_key) + + def get_happ_cryptolink_credentials(self) -> tuple[Optional[str], Optional[str]]: + provider_code = (self.HAPP_CRYPTOLINK_PROVIDER_CODE or "").strip() + auth_key = (self.HAPP_CRYPTOLINK_AUTH_KEY or "").strip() + return provider_code or None, auth_key or None + + def get_happ_cryptolink_install_limit(self, fallback_limit: Optional[int] = None) -> Optional[int]: + try: + limit = int(self.HAPP_CRYPTOLINK_INSTALL_LIMIT) + except (TypeError, ValueError): + limit = 0 + + if limit <= 0: + limit = fallback_limit or 0 + + if limit <= 0: + return None + + return limit + + def get_happ_cryptolink_add_install_url(self) -> str: + base_url = (self.HAPP_CRYPTOLINK_API_BASE_URL or "").rstrip("/") + return f"{base_url}/api/add-install" + + def get_happ_cryptolink_reset_cooldown(self) -> Optional[timedelta]: + try: + minutes = int(self.HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES) + except (TypeError, ValueError): + minutes = 0 + + if minutes <= 0: + return None + + return timedelta(minutes=max(1, minutes)) + def should_hide_subscription_link(self) -> bool: """Returns True when subscription links must be hidden from the interface.""" diff --git a/app/database/models.py b/app/database/models.py index 41887159..d6a50988 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -682,7 +682,9 @@ class Subscription(Base): subscription_crypto_link = Column(String, nullable=True) device_limit = Column(Integer, default=1) - + + last_devices_reset_at = Column(DateTime, nullable=True) + connected_squads = Column(JSON, default=list) autopay_enabled = Column(Boolean, default=False) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 058e3eab..c23cbbd0 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -3120,6 +3120,33 @@ async def add_subscription_crypto_link_column() -> bool: return False +async def add_last_devices_reset_column() -> bool: + column_exists = await check_column_exists('subscriptions', 'last_devices_reset_at') + if column_exists: + logger.info("ℹ️ Колонка last_devices_reset_at уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at DATETIME")) + elif db_type == 'postgresql': + await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at TIMESTAMP")) + elif db_type == 'mysql': + await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at DATETIME")) + else: + logger.error(f"Неподдерживаемый тип БД для добавления last_devices_reset_at: {db_type}") + return False + + logger.info("✅ Добавлена колонка last_devices_reset_at в таблицу subscriptions") + return True + except Exception as e: + logger.error(f"Ошибка добавления колонки last_devices_reset_at: {e}") + return False + + async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -4541,6 +4568,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link") + logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ LAST_DEVICES_RESET_AT ДЛЯ ПОДПИСОК ===") + devices_reset_column_added = await add_last_devices_reset_column() + if devices_reset_column_added: + logger.info("✅ Колонка last_devices_reset_at готова") + else: + logger.warning("⚠️ Проблемы с добавлением колонки last_devices_reset_at") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===") try: async with engine.begin() as conn: @@ -4702,6 +4736,7 @@ async def check_migration_status(): "subscription_duplicates": False, "subscription_conversions_table": False, "subscription_events_table": False, + "subscription_last_devices_reset_column": False, "promo_groups_table": False, "server_promo_groups_table": False, "server_squads_trial_column": False, @@ -4773,6 +4808,7 @@ async def check_migration_status(): status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at') status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') + status["subscription_last_devices_reset_column"] = await check_column_exists('subscriptions', 'last_devices_reset_at') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -4821,6 +4857,7 @@ async def check_migration_status(): "users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей", "users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", + "subscription_last_devices_reset_column": "Колонка last_devices_reset_at в subscriptions", "discount_offers_table": "Таблица discount_offers", "discount_offers_effect_column": "Колонка effect_type в discount_offers", "discount_offers_extra_column": "Колонка extra_data в discount_offers", diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 5c145416..f5c23fa2 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -4004,8 +4004,16 @@ async def reset_user_devices( remnawave_service = RemnaWaveService() async with remnawave_service.get_api_client() as api: success = await api.reset_user_devices(user.remnawave_uuid) - + if success: + try: + await remnawave_service.refresh_happ_subscription_after_reset(db, user) + except Exception as refresh_error: + logger.warning( + "⚠️ Не удалось обновить Happ ссылку после сброса устройств: %s", + refresh_error, + ) + await callback.message.edit_text( "✅ Устройства пользователя успешно сброшены", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index cecfdf22..e4cc7144 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -1,6 +1,7 @@ import base64 import json import logging +import math from datetime import datetime, timedelta from typing import Dict, List, Any, Tuple, Optional from urllib.parse import quote @@ -81,6 +82,27 @@ from app.utils.promo_offer import ( from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, format_additional_section, get_apps_for_device, get_device_name, get_step_description, logger from .countries import _get_available_countries + +def _format_cooldown_duration(seconds: int, language: str) -> str: + total_minutes = max(1, math.ceil(seconds / 60)) + days, remainder_minutes = divmod(total_minutes, 60 * 24) + hours, minutes = divmod(remainder_minutes, 60) + + language_code = (language or "ru").split("-")[0].lower() + if language_code == "en": + day_label, hour_label, minute_label = "d", "h", "m" + else: + day_label, hour_label, minute_label = "д", "ч", "м" + + parts: list[str] = [] + if days: + parts.append(f"{days}{day_label}") + if hours or days: + parts.append(f"{hours}{hour_label}") + parts.append(f"{minutes}{minute_label}") + + return " ".join(parts) + async def get_current_devices_detailed(db_user: User) -> dict: try: if not db_user.remnawave_uuid: @@ -772,6 +794,30 @@ async def handle_all_devices_reset_from_management( from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() + subscription = getattr(db_user, "subscription", None) + cooldown_remaining = ( + service.get_devices_reset_cooldown_remaining(subscription) + if subscription + else None + ) + + if cooldown_remaining: + remaining_seconds = int(cooldown_remaining.total_seconds()) + cooldown = settings.get_happ_cryptolink_reset_cooldown() + cooldown_seconds = int(cooldown.total_seconds()) if cooldown else remaining_seconds + + await callback.answer( + texts.t( + "DEVICE_RESET_COOLDOWN", + "⏳ Сброс устройств доступен раз в {cooldown}. Попробуйте через {remaining}.", + ).format( + cooldown=_format_cooldown_duration(cooldown_seconds, db_user.language), + remaining=_format_cooldown_duration(remaining_seconds, db_user.language), + ), + show_alert=True, + ) + return + async with service.get_api_client() as api: devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') @@ -819,6 +865,15 @@ async def handle_all_devices_reset_from_management( failed_count += 1 logger.warning(f"⚠️ У устройства нет HWID: {device}") + if success_count > 0: + try: + await service.refresh_happ_subscription_after_reset(db, db_user) + except Exception as refresh_error: + logger.warning( + "⚠️ Не удалось обновить Happ ссылку после сброса устройств: %s", + refresh_error, + ) + if success_count > 0: if failed_count == 0: await callback.message.edit_text( diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 02cf3d0f..e388999e 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -982,6 +982,7 @@ "DEVICE_RESET_ALL_DONE": "ℹ️ All devices have been reset", "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Couldn't reset devices\n\nPlease try again later or contact support.\n\nTotal devices: {total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ All devices have been reset!\n\n🔄 Reset: {count} devices\n📱 You can now reconnect your devices\n\n💡 Use the link from the 'My subscription' section to reconnect", + "DEVICE_RESET_COOLDOWN": "⏳ Device reset is available every {cooldown}. Try again in {remaining}.", "DEVICE_RESET_ERROR": "❌ Failed to reset the device", "DEVICE_RESET_ID_FAILED": "❌ Unable to get device ID", "DEVICE_RESET_INVALID_REQUEST": "❌ Error: invalid request", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index e1d2b5cb..de1f00c5 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -994,6 +994,7 @@ "DEVICE_RESET_ALL_DONE": "ℹ️ Все устройства сброшены", "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Не удалось сбросить устройства\n\nПопробуйте еще раз позже или обратитесь в техподдержку.\n\nВсего устройств: {total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ Все устройства успешно сброшены!\n\n🔄 Сброшено: {count} устройств\n📱 Теперь вы можете заново подключить свои устройства\n\n💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения", + "DEVICE_RESET_COOLDOWN": "⏳ Сброс устройств доступен раз в {cooldown}. Попробуйте через {remaining}.", "DEVICE_RESET_ERROR": "❌ Ошибка сброса устройства", "DEVICE_RESET_ID_FAILED": "❌ Не удалось получить ID устройства", "DEVICE_RESET_INVALID_REQUEST": "❌ Ошибка: некорректный запрос", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 69139498..4deacb1b 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -927,6 +927,7 @@ "DEVICE_RESET_ALL_DONE": "ℹ️ Всі пристрої скинуто", "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Не вдалося скинути пристрої\n\nСпробуйте ще раз пізніше або зверніться до техпідтримки.\n\nВсього пристроїв: {total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ Всі пристрої успішно скинуто!\n\n🔄 Скинуто: {count} пристроїв\n📱 Тепер ви можете заново підключити свої пристрої\n\n💡 Використовуйте посилання з розділу 'Моя підписка' для повторного підключення", + "DEVICE_RESET_COOLDOWN": "⏳ Скидання пристроїв доступне раз на {cooldown}. Спробуйте через {remaining}.", "DEVICE_RESET_ERROR": "❌ Помилка скидання пристрою", "DEVICE_RESET_ID_FAILED": "❌ Не вдалося отримати ID пристрою", "DEVICE_RESET_INVALID_REQUEST": "❌ Помилка: некоректний запит", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index a107bf81..0444d118 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -926,6 +926,7 @@ "DEVICE_RESET_ALL_DONE":"ℹ️所有设备已重置", "DEVICE_RESET_ALL_FAILED_MESSAGE":"❌重置设备失败\n\n请稍后再试或联系技术支持。\n\n总设备数:{total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE":"✅所有设备已成功重置!\n\n🔄已重置:{count}台设备\n📱您现在可以重新连接您的设备\n\n💡使用“我的订阅”部分中的链接重新连接", +"DEVICE_RESET_COOLDOWN":"⏳设备重置每隔{cooldown}可用一次。请在{remaining}后再试。", "DEVICE_RESET_ERROR":"❌重置设备失败", "DEVICE_RESET_ID_FAILED":"❌获取设备ID失败", "DEVICE_RESET_INVALID_REQUEST":"❌错误:请求无效", diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index c23cbda1..e654a29e 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -17,6 +17,7 @@ from sqlalchemy import and_, cast, delete, func, select, update, String from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession +from app.utils.happ_cryptolink_utils import generate_limited_happ_link from app.database.crud.user import ( create_user_no_commit, get_users_list, @@ -237,6 +238,49 @@ class RemnaWaveService: async with self.api as api: yield api + async def _build_happ_crypto_link( + self, + api: RemnaWaveAPI, + subscription: "Subscription", + base_subscription_url: Optional[str], + panel_crypto_link: Optional[str], + ) -> Optional[str]: + if not settings.is_happ_cryptolink_mode(): + return panel_crypto_link + + if not settings.is_happ_cryptolink_limited_links_enabled(): + return panel_crypto_link + + provider_code, auth_key = settings.get_happ_cryptolink_credentials() + if not provider_code or not auth_key: + logger.debug("⚙️ Нет данных для генерации лимитированной Happ ссылки") + return panel_crypto_link + + base_link = (base_subscription_url or "").strip() + if not base_link: + logger.warning("⚠️ Базовая ссылка подписки Happ отсутствует") + return panel_crypto_link + + install_limit = settings.get_happ_cryptolink_install_limit(getattr(subscription, "device_limit", None)) + if not install_limit: + logger.debug("⚙️ Лимит установок Happ не задан") + return panel_crypto_link + + limited_link = await generate_limited_happ_link( + base_link, + settings.get_happ_cryptolink_add_install_url(), + provider_code, + auth_key, + install_limit, + ) + + if not limited_link: + logger.warning("⚠️ Не удалось создать лимитированную Happ ссылку") + return panel_crypto_link + + encrypted_link = await api.encrypt_happ_crypto_link(limited_link) + return encrypted_link or limited_link + def _now_utc(self) -> datetime: """Возвращает текущее время в UTC без привязки к часовому поясу.""" return datetime.now(self._utc_timezone).replace(tzinfo=None) @@ -2007,6 +2051,79 @@ class RemnaWaveService: logger.error(f"Ошибка валидации данных пользователя: {e}") return False + def get_devices_reset_cooldown_remaining(self, subscription: "Subscription") -> Optional[timedelta]: + if not settings.is_happ_cryptolink_mode() or not settings.is_happ_cryptolink_limited_links_enabled(): + return None + + cooldown = settings.get_happ_cryptolink_reset_cooldown() + if not cooldown: + return None + + last_reset = getattr(subscription, "last_devices_reset_at", None) + if not last_reset: + return None + + now = datetime.utcnow() + next_allowed = last_reset + cooldown + if now >= next_allowed: + return None + + return next_allowed - now + + async def refresh_happ_subscription_after_reset( + self, + db: AsyncSession, + user: "User", + ) -> Optional[str]: + if not settings.is_happ_cryptolink_mode() or not settings.is_happ_cryptolink_limited_links_enabled(): + return None + + if not getattr(user, "remnawave_uuid", None): + logger.debug("⚙️ Нет RemnaWave UUID для обновления Happ ссылки") + return None + + subscription = getattr(user, "subscription", None) + if not subscription: + logger.debug("⚙️ У пользователя нет подписки для обновления Happ ссылки") + return None + + try: + async with self.get_api_client() as api: + revoked_user = await api.revoke_user_subscription(user.remnawave_uuid) + + subscription.remnawave_short_uuid = revoked_user.short_uuid + subscription.subscription_url = revoked_user.subscription_url + + crypto_link = await self._build_happ_crypto_link( + api, + subscription, + revoked_user.subscription_url, + revoked_user.happ_crypto_link, + ) + + subscription.subscription_crypto_link = crypto_link or revoked_user.happ_crypto_link + subscription.last_devices_reset_at = datetime.utcnow() + + await db.commit() + + logger.info( + "🔄 Обновлена Happ ссылка после сброса устройств пользователя %s", + getattr(user, "telegram_id", "?"), + ) + + return subscription.subscription_crypto_link + + except Exception as error: + logger.error( + "❌ Ошибка обновления Happ ссылки после сброса устройств: %s", + error, + ) + try: + await db.rollback() + except Exception: + pass + return None + async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool: try: logger.info(f"🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user.telegram_id}") diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index f7a29e2f..d325f651 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -20,6 +20,7 @@ from app.utils.pricing_utils import ( from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) +from app.utils.happ_cryptolink_utils import generate_limited_happ_link logger = logging.getLogger(__name__) @@ -160,6 +161,49 @@ class SubscriptionService: assert self.api is not None async with self.api as api: yield api + + async def _build_happ_crypto_link( + self, + api: RemnaWaveAPI, + subscription: Subscription, + base_subscription_url: Optional[str], + panel_crypto_link: Optional[str], + ) -> Optional[str]: + if not settings.is_happ_cryptolink_mode(): + return panel_crypto_link + + if not settings.is_happ_cryptolink_limited_links_enabled(): + return panel_crypto_link + + provider_code, auth_key = settings.get_happ_cryptolink_credentials() + if not provider_code or not auth_key: + logger.debug("⚙️ Данные для лимитированных ссылок Happ не заданы") + return panel_crypto_link + + base_link = (base_subscription_url or "").strip() + if not base_link: + logger.warning("⚠️ Базовая ссылка подписки для Happ отсутствует") + return panel_crypto_link + + install_limit = settings.get_happ_cryptolink_install_limit(subscription.device_limit) + if not install_limit: + logger.debug("⚙️ Лимит установок для Happ не задан") + return panel_crypto_link + + limited_link = await generate_limited_happ_link( + base_link, + settings.get_happ_cryptolink_add_install_url(), + provider_code, + auth_key, + install_limit, + ) + + if not limited_link: + logger.warning("⚠️ Не удалось сгенерировать лимитированную Happ ссылку") + return panel_crypto_link + + encrypted_link = await api.encrypt_happ_crypto_link(limited_link) + return encrypted_link or limited_link async def create_remnawave_user( self, @@ -266,7 +310,15 @@ class SubscriptionService: subscription.remnawave_short_uuid = updated_user.short_uuid subscription.subscription_url = updated_user.subscription_url - subscription.subscription_crypto_link = updated_user.happ_crypto_link + + crypto_link = await self._build_happ_crypto_link( + api, + subscription, + updated_user.subscription_url, + updated_user.happ_crypto_link, + ) + + subscription.subscription_crypto_link = crypto_link or updated_user.happ_crypto_link user.remnawave_uuid = updated_user.uuid await db.commit() @@ -348,7 +400,15 @@ class SubscriptionService: ) subscription.subscription_url = updated_user.subscription_url - subscription.subscription_crypto_link = updated_user.happ_crypto_link + + crypto_link = await self._build_happ_crypto_link( + api, + subscription, + updated_user.subscription_url, + updated_user.happ_crypto_link, + ) + + subscription.subscription_crypto_link = crypto_link or updated_user.happ_crypto_link await db.commit() status_text = "активным" if is_actually_active else "истёкшим" diff --git a/app/utils/happ_cryptolink_utils.py b/app/utils/happ_cryptolink_utils.py new file mode 100644 index 00000000..d808fa7d --- /dev/null +++ b/app/utils/happ_cryptolink_utils.py @@ -0,0 +1,80 @@ +from typing import Optional +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import aiohttp + + +def _append_query_param_to_fragment(fragment: str, param: str, value: str) -> str: + base_part, _, query_part = fragment.partition("?") + query_params = dict(parse_qsl(query_part, keep_blank_values=True)) + query_params[param] = value + + encoded_query = urlencode(query_params) + return f"{base_part}?{encoded_query}" if base_part else encoded_query + + +def append_install_code(base_link: str, install_code: str, param_name: str = "installid") -> str: + """Добавляет параметр installid в ссылку, сохраняя существующие параметры.""" + + if not install_code: + return base_link + + parsed = urlsplit(base_link) + + if parsed.fragment: + updated_fragment = _append_query_param_to_fragment(parsed.fragment, param_name, install_code) + return urlunsplit(( + parsed.scheme, + parsed.netloc, + parsed.path, + parsed.query, + updated_fragment, + )) + + query_params = dict(parse_qsl(parsed.query, keep_blank_values=True)) + query_params[param_name] = install_code + updated_query = urlencode(query_params) + + return urlunsplit(( + parsed.scheme, + parsed.netloc, + parsed.path, + updated_query, + parsed.fragment, + )) + + +async def generate_limited_happ_link( + base_link: str, + api_url: str, + provider_code: str, + auth_key: str, + install_limit: int, + *, + timeout_seconds: int = 15, +) -> Optional[str]: + if not base_link or install_limit <= 0: + return None + + timeout = aiohttp.ClientTimeout(total=timeout_seconds) + params = { + "provider_code": provider_code, + "auth_key": auth_key, + "install_limit": install_limit, + } + + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(api_url, params=params) as response: + data = await response.json() + except Exception: + return None + + if data.get("rc") != 1: + return None + + install_code = data.get("install_code") or data.get("installCode") + if not install_code: + return None + + return append_install_code(base_link, install_code) diff --git a/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py b/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py new file mode 100644 index 00000000..262dd488 --- /dev/null +++ b/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py @@ -0,0 +1,21 @@ +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "f6c5c60dba2e" +down_revision: Union[str, None] = "e3c1e0b5b4a7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "subscriptions", + sa.Column("last_devices_reset_at", sa.DateTime(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("subscriptions", "last_devices_reset_at") From 80388be54f565fbb19ff65a1440715c76c54a0b5 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 15 Dec 2025 11:00:17 +0300 Subject: [PATCH 4/7] Revert "Add limited Happ cryptolink support" --- .env.example | 6 - app/config.py | 52 +------- app/database/models.py | 4 +- app/database/universal_migration.py | 37 ------ app/handlers/admin/users.py | 10 +- app/handlers/subscription/devices.py | 55 -------- app/localization/locales/en.json | 1 - app/localization/locales/ru.json | 1 - app/localization/locales/ua.json | 1 - app/localization/locales/zh.json | 1 - app/services/remnawave_service.py | 117 ------------------ app/services/subscription_service.py | 64 +--------- app/utils/happ_cryptolink_utils.py | 80 ------------ ...6c5c60dba2e_add_devices_reset_timestamp.py | 21 ---- 14 files changed, 5 insertions(+), 445 deletions(-) delete mode 100644 app/utils/happ_cryptolink_utils.py delete mode 100644 migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py diff --git a/.env.example b/.env.example index ad38c3da..0fc1e422 100644 --- a/.env.example +++ b/.env.example @@ -396,12 +396,6 @@ MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подкл # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false -HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED=false -HAPP_CRYPTOLINK_PROVIDER_CODE= -HAPP_CRYPTOLINK_AUTH_KEY= -HAPP_CRYPTOLINK_INSTALL_LIMIT=0 -HAPP_CRYPTOLINK_API_BASE_URL=https://api.happ-proxy.com -HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES=0 HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_MACOS= diff --git a/app/config.py b/app/config.py index 438a84ed..9a3ee4e9 100644 --- a/app/config.py +++ b/app/config.py @@ -6,7 +6,7 @@ import os import re import html from collections import defaultdict -from datetime import time, timedelta +from datetime import time from typing import Dict, List, Optional, Union from urllib.parse import urlparse from zoneinfo import ZoneInfo @@ -339,12 +339,6 @@ class Settings(BaseSettings): MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None - HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED: bool = False - HAPP_CRYPTOLINK_PROVIDER_CODE: Optional[str] = None - HAPP_CRYPTOLINK_AUTH_KEY: Optional[str] = None - HAPP_CRYPTOLINK_INSTALL_LIMIT: int = 0 - HAPP_CRYPTOLINK_API_BASE_URL: str = "https://api.happ-proxy.com" - HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES: int = 0 HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None @@ -1194,50 +1188,6 @@ class Settings(BaseSettings): def is_happ_download_button_enabled(self) -> bool: return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED - def is_happ_cryptolink_limited_links_enabled(self) -> bool: - if not self.is_happ_cryptolink_mode(): - return False - - if not self.HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED: - return False - - provider_code, auth_key = self.get_happ_cryptolink_credentials() - return bool(provider_code and auth_key) - - def get_happ_cryptolink_credentials(self) -> tuple[Optional[str], Optional[str]]: - provider_code = (self.HAPP_CRYPTOLINK_PROVIDER_CODE or "").strip() - auth_key = (self.HAPP_CRYPTOLINK_AUTH_KEY or "").strip() - return provider_code or None, auth_key or None - - def get_happ_cryptolink_install_limit(self, fallback_limit: Optional[int] = None) -> Optional[int]: - try: - limit = int(self.HAPP_CRYPTOLINK_INSTALL_LIMIT) - except (TypeError, ValueError): - limit = 0 - - if limit <= 0: - limit = fallback_limit or 0 - - if limit <= 0: - return None - - return limit - - def get_happ_cryptolink_add_install_url(self) -> str: - base_url = (self.HAPP_CRYPTOLINK_API_BASE_URL or "").rstrip("/") - return f"{base_url}/api/add-install" - - def get_happ_cryptolink_reset_cooldown(self) -> Optional[timedelta]: - try: - minutes = int(self.HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES) - except (TypeError, ValueError): - minutes = 0 - - if minutes <= 0: - return None - - return timedelta(minutes=max(1, minutes)) - def should_hide_subscription_link(self) -> bool: """Returns True when subscription links must be hidden from the interface.""" diff --git a/app/database/models.py b/app/database/models.py index d6a50988..41887159 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -682,9 +682,7 @@ class Subscription(Base): subscription_crypto_link = Column(String, nullable=True) device_limit = Column(Integer, default=1) - - last_devices_reset_at = Column(DateTime, nullable=True) - + connected_squads = Column(JSON, default=list) autopay_enabled = Column(Boolean, default=False) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index c23cbbd0..058e3eab 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -3120,33 +3120,6 @@ async def add_subscription_crypto_link_column() -> bool: return False -async def add_last_devices_reset_column() -> bool: - column_exists = await check_column_exists('subscriptions', 'last_devices_reset_at') - if column_exists: - logger.info("ℹ️ Колонка last_devices_reset_at уже существует") - return True - - try: - async with engine.begin() as conn: - db_type = await get_database_type() - - if db_type == 'sqlite': - await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at DATETIME")) - elif db_type == 'postgresql': - await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at TIMESTAMP")) - elif db_type == 'mysql': - await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at DATETIME")) - else: - logger.error(f"Неподдерживаемый тип БД для добавления last_devices_reset_at: {db_type}") - return False - - logger.info("✅ Добавлена колонка last_devices_reset_at в таблицу subscriptions") - return True - except Exception as e: - logger.error(f"Ошибка добавления колонки last_devices_reset_at: {e}") - return False - - async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -4568,13 +4541,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link") - logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ LAST_DEVICES_RESET_AT ДЛЯ ПОДПИСОК ===") - devices_reset_column_added = await add_last_devices_reset_column() - if devices_reset_column_added: - logger.info("✅ Колонка last_devices_reset_at готова") - else: - logger.warning("⚠️ Проблемы с добавлением колонки last_devices_reset_at") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===") try: async with engine.begin() as conn: @@ -4736,7 +4702,6 @@ async def check_migration_status(): "subscription_duplicates": False, "subscription_conversions_table": False, "subscription_events_table": False, - "subscription_last_devices_reset_column": False, "promo_groups_table": False, "server_promo_groups_table": False, "server_squads_trial_column": False, @@ -4808,7 +4773,6 @@ async def check_migration_status(): status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at') status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') - status["subscription_last_devices_reset_column"] = await check_column_exists('subscriptions', 'last_devices_reset_at') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -4857,7 +4821,6 @@ async def check_migration_status(): "users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей", "users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", - "subscription_last_devices_reset_column": "Колонка last_devices_reset_at в subscriptions", "discount_offers_table": "Таблица discount_offers", "discount_offers_effect_column": "Колонка effect_type в discount_offers", "discount_offers_extra_column": "Колонка extra_data в discount_offers", diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index f5c23fa2..5c145416 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -4004,16 +4004,8 @@ async def reset_user_devices( remnawave_service = RemnaWaveService() async with remnawave_service.get_api_client() as api: success = await api.reset_user_devices(user.remnawave_uuid) - + if success: - try: - await remnawave_service.refresh_happ_subscription_after_reset(db, user) - except Exception as refresh_error: - logger.warning( - "⚠️ Не удалось обновить Happ ссылку после сброса устройств: %s", - refresh_error, - ) - await callback.message.edit_text( "✅ Устройства пользователя успешно сброшены", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index e4cc7144..cecfdf22 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -1,7 +1,6 @@ import base64 import json import logging -import math from datetime import datetime, timedelta from typing import Dict, List, Any, Tuple, Optional from urllib.parse import quote @@ -82,27 +81,6 @@ from app.utils.promo_offer import ( from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, format_additional_section, get_apps_for_device, get_device_name, get_step_description, logger from .countries import _get_available_countries - -def _format_cooldown_duration(seconds: int, language: str) -> str: - total_minutes = max(1, math.ceil(seconds / 60)) - days, remainder_minutes = divmod(total_minutes, 60 * 24) - hours, minutes = divmod(remainder_minutes, 60) - - language_code = (language or "ru").split("-")[0].lower() - if language_code == "en": - day_label, hour_label, minute_label = "d", "h", "m" - else: - day_label, hour_label, minute_label = "д", "ч", "м" - - parts: list[str] = [] - if days: - parts.append(f"{days}{day_label}") - if hours or days: - parts.append(f"{hours}{hour_label}") - parts.append(f"{minutes}{minute_label}") - - return " ".join(parts) - async def get_current_devices_detailed(db_user: User) -> dict: try: if not db_user.remnawave_uuid: @@ -794,30 +772,6 @@ async def handle_all_devices_reset_from_management( from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - subscription = getattr(db_user, "subscription", None) - cooldown_remaining = ( - service.get_devices_reset_cooldown_remaining(subscription) - if subscription - else None - ) - - if cooldown_remaining: - remaining_seconds = int(cooldown_remaining.total_seconds()) - cooldown = settings.get_happ_cryptolink_reset_cooldown() - cooldown_seconds = int(cooldown.total_seconds()) if cooldown else remaining_seconds - - await callback.answer( - texts.t( - "DEVICE_RESET_COOLDOWN", - "⏳ Сброс устройств доступен раз в {cooldown}. Попробуйте через {remaining}.", - ).format( - cooldown=_format_cooldown_duration(cooldown_seconds, db_user.language), - remaining=_format_cooldown_duration(remaining_seconds, db_user.language), - ), - show_alert=True, - ) - return - async with service.get_api_client() as api: devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') @@ -865,15 +819,6 @@ async def handle_all_devices_reset_from_management( failed_count += 1 logger.warning(f"⚠️ У устройства нет HWID: {device}") - if success_count > 0: - try: - await service.refresh_happ_subscription_after_reset(db, db_user) - except Exception as refresh_error: - logger.warning( - "⚠️ Не удалось обновить Happ ссылку после сброса устройств: %s", - refresh_error, - ) - if success_count > 0: if failed_count == 0: await callback.message.edit_text( diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index e388999e..02cf3d0f 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -982,7 +982,6 @@ "DEVICE_RESET_ALL_DONE": "ℹ️ All devices have been reset", "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Couldn't reset devices\n\nPlease try again later or contact support.\n\nTotal devices: {total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ All devices have been reset!\n\n🔄 Reset: {count} devices\n📱 You can now reconnect your devices\n\n💡 Use the link from the 'My subscription' section to reconnect", - "DEVICE_RESET_COOLDOWN": "⏳ Device reset is available every {cooldown}. Try again in {remaining}.", "DEVICE_RESET_ERROR": "❌ Failed to reset the device", "DEVICE_RESET_ID_FAILED": "❌ Unable to get device ID", "DEVICE_RESET_INVALID_REQUEST": "❌ Error: invalid request", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index de1f00c5..e1d2b5cb 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -994,7 +994,6 @@ "DEVICE_RESET_ALL_DONE": "ℹ️ Все устройства сброшены", "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Не удалось сбросить устройства\n\nПопробуйте еще раз позже или обратитесь в техподдержку.\n\nВсего устройств: {total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ Все устройства успешно сброшены!\n\n🔄 Сброшено: {count} устройств\n📱 Теперь вы можете заново подключить свои устройства\n\n💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения", - "DEVICE_RESET_COOLDOWN": "⏳ Сброс устройств доступен раз в {cooldown}. Попробуйте через {remaining}.", "DEVICE_RESET_ERROR": "❌ Ошибка сброса устройства", "DEVICE_RESET_ID_FAILED": "❌ Не удалось получить ID устройства", "DEVICE_RESET_INVALID_REQUEST": "❌ Ошибка: некорректный запрос", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 4deacb1b..69139498 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -927,7 +927,6 @@ "DEVICE_RESET_ALL_DONE": "ℹ️ Всі пристрої скинуто", "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Не вдалося скинути пристрої\n\nСпробуйте ще раз пізніше або зверніться до техпідтримки.\n\nВсього пристроїв: {total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ Всі пристрої успішно скинуто!\n\n🔄 Скинуто: {count} пристроїв\n📱 Тепер ви можете заново підключити свої пристрої\n\n💡 Використовуйте посилання з розділу 'Моя підписка' для повторного підключення", - "DEVICE_RESET_COOLDOWN": "⏳ Скидання пристроїв доступне раз на {cooldown}. Спробуйте через {remaining}.", "DEVICE_RESET_ERROR": "❌ Помилка скидання пристрою", "DEVICE_RESET_ID_FAILED": "❌ Не вдалося отримати ID пристрою", "DEVICE_RESET_INVALID_REQUEST": "❌ Помилка: некоректний запит", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 0444d118..a107bf81 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -926,7 +926,6 @@ "DEVICE_RESET_ALL_DONE":"ℹ️所有设备已重置", "DEVICE_RESET_ALL_FAILED_MESSAGE":"❌重置设备失败\n\n请稍后再试或联系技术支持。\n\n总设备数:{total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE":"✅所有设备已成功重置!\n\n🔄已重置:{count}台设备\n📱您现在可以重新连接您的设备\n\n💡使用“我的订阅”部分中的链接重新连接", -"DEVICE_RESET_COOLDOWN":"⏳设备重置每隔{cooldown}可用一次。请在{remaining}后再试。", "DEVICE_RESET_ERROR":"❌重置设备失败", "DEVICE_RESET_ID_FAILED":"❌获取设备ID失败", "DEVICE_RESET_INVALID_REQUEST":"❌错误:请求无效", diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index e654a29e..c23cbda1 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -17,7 +17,6 @@ from sqlalchemy import and_, cast, delete, func, select, update, String from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession -from app.utils.happ_cryptolink_utils import generate_limited_happ_link from app.database.crud.user import ( create_user_no_commit, get_users_list, @@ -238,49 +237,6 @@ class RemnaWaveService: async with self.api as api: yield api - async def _build_happ_crypto_link( - self, - api: RemnaWaveAPI, - subscription: "Subscription", - base_subscription_url: Optional[str], - panel_crypto_link: Optional[str], - ) -> Optional[str]: - if not settings.is_happ_cryptolink_mode(): - return panel_crypto_link - - if not settings.is_happ_cryptolink_limited_links_enabled(): - return panel_crypto_link - - provider_code, auth_key = settings.get_happ_cryptolink_credentials() - if not provider_code or not auth_key: - logger.debug("⚙️ Нет данных для генерации лимитированной Happ ссылки") - return panel_crypto_link - - base_link = (base_subscription_url or "").strip() - if not base_link: - logger.warning("⚠️ Базовая ссылка подписки Happ отсутствует") - return panel_crypto_link - - install_limit = settings.get_happ_cryptolink_install_limit(getattr(subscription, "device_limit", None)) - if not install_limit: - logger.debug("⚙️ Лимит установок Happ не задан") - return panel_crypto_link - - limited_link = await generate_limited_happ_link( - base_link, - settings.get_happ_cryptolink_add_install_url(), - provider_code, - auth_key, - install_limit, - ) - - if not limited_link: - logger.warning("⚠️ Не удалось создать лимитированную Happ ссылку") - return panel_crypto_link - - encrypted_link = await api.encrypt_happ_crypto_link(limited_link) - return encrypted_link or limited_link - def _now_utc(self) -> datetime: """Возвращает текущее время в UTC без привязки к часовому поясу.""" return datetime.now(self._utc_timezone).replace(tzinfo=None) @@ -2051,79 +2007,6 @@ class RemnaWaveService: logger.error(f"Ошибка валидации данных пользователя: {e}") return False - def get_devices_reset_cooldown_remaining(self, subscription: "Subscription") -> Optional[timedelta]: - if not settings.is_happ_cryptolink_mode() or not settings.is_happ_cryptolink_limited_links_enabled(): - return None - - cooldown = settings.get_happ_cryptolink_reset_cooldown() - if not cooldown: - return None - - last_reset = getattr(subscription, "last_devices_reset_at", None) - if not last_reset: - return None - - now = datetime.utcnow() - next_allowed = last_reset + cooldown - if now >= next_allowed: - return None - - return next_allowed - now - - async def refresh_happ_subscription_after_reset( - self, - db: AsyncSession, - user: "User", - ) -> Optional[str]: - if not settings.is_happ_cryptolink_mode() or not settings.is_happ_cryptolink_limited_links_enabled(): - return None - - if not getattr(user, "remnawave_uuid", None): - logger.debug("⚙️ Нет RemnaWave UUID для обновления Happ ссылки") - return None - - subscription = getattr(user, "subscription", None) - if not subscription: - logger.debug("⚙️ У пользователя нет подписки для обновления Happ ссылки") - return None - - try: - async with self.get_api_client() as api: - revoked_user = await api.revoke_user_subscription(user.remnawave_uuid) - - subscription.remnawave_short_uuid = revoked_user.short_uuid - subscription.subscription_url = revoked_user.subscription_url - - crypto_link = await self._build_happ_crypto_link( - api, - subscription, - revoked_user.subscription_url, - revoked_user.happ_crypto_link, - ) - - subscription.subscription_crypto_link = crypto_link or revoked_user.happ_crypto_link - subscription.last_devices_reset_at = datetime.utcnow() - - await db.commit() - - logger.info( - "🔄 Обновлена Happ ссылка после сброса устройств пользователя %s", - getattr(user, "telegram_id", "?"), - ) - - return subscription.subscription_crypto_link - - except Exception as error: - logger.error( - "❌ Ошибка обновления Happ ссылки после сброса устройств: %s", - error, - ) - try: - await db.rollback() - except Exception: - pass - return None - async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool: try: logger.info(f"🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user.telegram_id}") diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index d325f651..f7a29e2f 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -20,7 +20,6 @@ from app.utils.pricing_utils import ( from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) -from app.utils.happ_cryptolink_utils import generate_limited_happ_link logger = logging.getLogger(__name__) @@ -161,49 +160,6 @@ class SubscriptionService: assert self.api is not None async with self.api as api: yield api - - async def _build_happ_crypto_link( - self, - api: RemnaWaveAPI, - subscription: Subscription, - base_subscription_url: Optional[str], - panel_crypto_link: Optional[str], - ) -> Optional[str]: - if not settings.is_happ_cryptolink_mode(): - return panel_crypto_link - - if not settings.is_happ_cryptolink_limited_links_enabled(): - return panel_crypto_link - - provider_code, auth_key = settings.get_happ_cryptolink_credentials() - if not provider_code or not auth_key: - logger.debug("⚙️ Данные для лимитированных ссылок Happ не заданы") - return panel_crypto_link - - base_link = (base_subscription_url or "").strip() - if not base_link: - logger.warning("⚠️ Базовая ссылка подписки для Happ отсутствует") - return panel_crypto_link - - install_limit = settings.get_happ_cryptolink_install_limit(subscription.device_limit) - if not install_limit: - logger.debug("⚙️ Лимит установок для Happ не задан") - return panel_crypto_link - - limited_link = await generate_limited_happ_link( - base_link, - settings.get_happ_cryptolink_add_install_url(), - provider_code, - auth_key, - install_limit, - ) - - if not limited_link: - logger.warning("⚠️ Не удалось сгенерировать лимитированную Happ ссылку") - return panel_crypto_link - - encrypted_link = await api.encrypt_happ_crypto_link(limited_link) - return encrypted_link or limited_link async def create_remnawave_user( self, @@ -310,15 +266,7 @@ class SubscriptionService: subscription.remnawave_short_uuid = updated_user.short_uuid subscription.subscription_url = updated_user.subscription_url - - crypto_link = await self._build_happ_crypto_link( - api, - subscription, - updated_user.subscription_url, - updated_user.happ_crypto_link, - ) - - subscription.subscription_crypto_link = crypto_link or updated_user.happ_crypto_link + subscription.subscription_crypto_link = updated_user.happ_crypto_link user.remnawave_uuid = updated_user.uuid await db.commit() @@ -400,15 +348,7 @@ class SubscriptionService: ) subscription.subscription_url = updated_user.subscription_url - - crypto_link = await self._build_happ_crypto_link( - api, - subscription, - updated_user.subscription_url, - updated_user.happ_crypto_link, - ) - - subscription.subscription_crypto_link = crypto_link or updated_user.happ_crypto_link + subscription.subscription_crypto_link = updated_user.happ_crypto_link await db.commit() status_text = "активным" if is_actually_active else "истёкшим" diff --git a/app/utils/happ_cryptolink_utils.py b/app/utils/happ_cryptolink_utils.py deleted file mode 100644 index d808fa7d..00000000 --- a/app/utils/happ_cryptolink_utils.py +++ /dev/null @@ -1,80 +0,0 @@ -from typing import Optional -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit - -import aiohttp - - -def _append_query_param_to_fragment(fragment: str, param: str, value: str) -> str: - base_part, _, query_part = fragment.partition("?") - query_params = dict(parse_qsl(query_part, keep_blank_values=True)) - query_params[param] = value - - encoded_query = urlencode(query_params) - return f"{base_part}?{encoded_query}" if base_part else encoded_query - - -def append_install_code(base_link: str, install_code: str, param_name: str = "installid") -> str: - """Добавляет параметр installid в ссылку, сохраняя существующие параметры.""" - - if not install_code: - return base_link - - parsed = urlsplit(base_link) - - if parsed.fragment: - updated_fragment = _append_query_param_to_fragment(parsed.fragment, param_name, install_code) - return urlunsplit(( - parsed.scheme, - parsed.netloc, - parsed.path, - parsed.query, - updated_fragment, - )) - - query_params = dict(parse_qsl(parsed.query, keep_blank_values=True)) - query_params[param_name] = install_code - updated_query = urlencode(query_params) - - return urlunsplit(( - parsed.scheme, - parsed.netloc, - parsed.path, - updated_query, - parsed.fragment, - )) - - -async def generate_limited_happ_link( - base_link: str, - api_url: str, - provider_code: str, - auth_key: str, - install_limit: int, - *, - timeout_seconds: int = 15, -) -> Optional[str]: - if not base_link or install_limit <= 0: - return None - - timeout = aiohttp.ClientTimeout(total=timeout_seconds) - params = { - "provider_code": provider_code, - "auth_key": auth_key, - "install_limit": install_limit, - } - - try: - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get(api_url, params=params) as response: - data = await response.json() - except Exception: - return None - - if data.get("rc") != 1: - return None - - install_code = data.get("install_code") or data.get("installCode") - if not install_code: - return None - - return append_install_code(base_link, install_code) diff --git a/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py b/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py deleted file mode 100644 index 262dd488..00000000 --- a/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision: str = "f6c5c60dba2e" -down_revision: Union[str, None] = "e3c1e0b5b4a7" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column( - "subscriptions", - sa.Column("last_devices_reset_at", sa.DateTime(), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("subscriptions", "last_devices_reset_at") From f2a5032f1537eb26e998ac7dd34b98ec0ef7d59e Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 15 Dec 2025 11:00:39 +0300 Subject: [PATCH 5/7] Refresh Happ link before displaying subscription --- .env.example | 6 + app/config.py | 52 +++++++- app/database/models.py | 4 +- app/database/universal_migration.py | 37 ++++++ app/handlers/admin/users.py | 10 +- app/handlers/subscription/devices.py | 55 ++++++++ app/handlers/subscription/links.py | 14 +++ app/localization/locales/en.json | 1 + app/localization/locales/ru.json | 1 + app/localization/locales/ua.json | 1 + app/localization/locales/zh.json | 1 + app/services/remnawave_service.py | 117 ++++++++++++++++++ app/services/subscription_service.py | 64 +++++++++- app/utils/happ_cryptolink_utils.py | 80 ++++++++++++ ...6c5c60dba2e_add_devices_reset_timestamp.py | 21 ++++ 15 files changed, 459 insertions(+), 5 deletions(-) create mode 100644 app/utils/happ_cryptolink_utils.py create mode 100644 migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py diff --git a/.env.example b/.env.example index 0fc1e422..ad38c3da 100644 --- a/.env.example +++ b/.env.example @@ -396,6 +396,12 @@ MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подкл # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false +HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED=false +HAPP_CRYPTOLINK_PROVIDER_CODE= +HAPP_CRYPTOLINK_AUTH_KEY= +HAPP_CRYPTOLINK_INSTALL_LIMIT=0 +HAPP_CRYPTOLINK_API_BASE_URL=https://api.happ-proxy.com +HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES=0 HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_MACOS= diff --git a/app/config.py b/app/config.py index 9a3ee4e9..438a84ed 100644 --- a/app/config.py +++ b/app/config.py @@ -6,7 +6,7 @@ import os import re import html from collections import defaultdict -from datetime import time +from datetime import time, timedelta from typing import Dict, List, Optional, Union from urllib.parse import urlparse from zoneinfo import ZoneInfo @@ -339,6 +339,12 @@ class Settings(BaseSettings): MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None + HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED: bool = False + HAPP_CRYPTOLINK_PROVIDER_CODE: Optional[str] = None + HAPP_CRYPTOLINK_AUTH_KEY: Optional[str] = None + HAPP_CRYPTOLINK_INSTALL_LIMIT: int = 0 + HAPP_CRYPTOLINK_API_BASE_URL: str = "https://api.happ-proxy.com" + HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES: int = 0 HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None @@ -1188,6 +1194,50 @@ class Settings(BaseSettings): def is_happ_download_button_enabled(self) -> bool: return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED + def is_happ_cryptolink_limited_links_enabled(self) -> bool: + if not self.is_happ_cryptolink_mode(): + return False + + if not self.HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED: + return False + + provider_code, auth_key = self.get_happ_cryptolink_credentials() + return bool(provider_code and auth_key) + + def get_happ_cryptolink_credentials(self) -> tuple[Optional[str], Optional[str]]: + provider_code = (self.HAPP_CRYPTOLINK_PROVIDER_CODE or "").strip() + auth_key = (self.HAPP_CRYPTOLINK_AUTH_KEY or "").strip() + return provider_code or None, auth_key or None + + def get_happ_cryptolink_install_limit(self, fallback_limit: Optional[int] = None) -> Optional[int]: + try: + limit = int(self.HAPP_CRYPTOLINK_INSTALL_LIMIT) + except (TypeError, ValueError): + limit = 0 + + if limit <= 0: + limit = fallback_limit or 0 + + if limit <= 0: + return None + + return limit + + def get_happ_cryptolink_add_install_url(self) -> str: + base_url = (self.HAPP_CRYPTOLINK_API_BASE_URL or "").rstrip("/") + return f"{base_url}/api/add-install" + + def get_happ_cryptolink_reset_cooldown(self) -> Optional[timedelta]: + try: + minutes = int(self.HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES) + except (TypeError, ValueError): + minutes = 0 + + if minutes <= 0: + return None + + return timedelta(minutes=max(1, minutes)) + def should_hide_subscription_link(self) -> bool: """Returns True when subscription links must be hidden from the interface.""" diff --git a/app/database/models.py b/app/database/models.py index 41887159..d6a50988 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -682,7 +682,9 @@ class Subscription(Base): subscription_crypto_link = Column(String, nullable=True) device_limit = Column(Integer, default=1) - + + last_devices_reset_at = Column(DateTime, nullable=True) + connected_squads = Column(JSON, default=list) autopay_enabled = Column(Boolean, default=False) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 058e3eab..c23cbbd0 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -3120,6 +3120,33 @@ async def add_subscription_crypto_link_column() -> bool: return False +async def add_last_devices_reset_column() -> bool: + column_exists = await check_column_exists('subscriptions', 'last_devices_reset_at') + if column_exists: + logger.info("ℹ️ Колонка last_devices_reset_at уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at DATETIME")) + elif db_type == 'postgresql': + await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at TIMESTAMP")) + elif db_type == 'mysql': + await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at DATETIME")) + else: + logger.error(f"Неподдерживаемый тип БД для добавления last_devices_reset_at: {db_type}") + return False + + logger.info("✅ Добавлена колонка last_devices_reset_at в таблицу subscriptions") + return True + except Exception as e: + logger.error(f"Ошибка добавления колонки last_devices_reset_at: {e}") + return False + + async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -4541,6 +4568,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link") + logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ LAST_DEVICES_RESET_AT ДЛЯ ПОДПИСОК ===") + devices_reset_column_added = await add_last_devices_reset_column() + if devices_reset_column_added: + logger.info("✅ Колонка last_devices_reset_at готова") + else: + logger.warning("⚠️ Проблемы с добавлением колонки last_devices_reset_at") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===") try: async with engine.begin() as conn: @@ -4702,6 +4736,7 @@ async def check_migration_status(): "subscription_duplicates": False, "subscription_conversions_table": False, "subscription_events_table": False, + "subscription_last_devices_reset_column": False, "promo_groups_table": False, "server_promo_groups_table": False, "server_squads_trial_column": False, @@ -4773,6 +4808,7 @@ async def check_migration_status(): status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at') status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') + status["subscription_last_devices_reset_column"] = await check_column_exists('subscriptions', 'last_devices_reset_at') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -4821,6 +4857,7 @@ async def check_migration_status(): "users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей", "users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", + "subscription_last_devices_reset_column": "Колонка last_devices_reset_at в subscriptions", "discount_offers_table": "Таблица discount_offers", "discount_offers_effect_column": "Колонка effect_type в discount_offers", "discount_offers_extra_column": "Колонка extra_data в discount_offers", diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 5c145416..f5c23fa2 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -4004,8 +4004,16 @@ async def reset_user_devices( remnawave_service = RemnaWaveService() async with remnawave_service.get_api_client() as api: success = await api.reset_user_devices(user.remnawave_uuid) - + if success: + try: + await remnawave_service.refresh_happ_subscription_after_reset(db, user) + except Exception as refresh_error: + logger.warning( + "⚠️ Не удалось обновить Happ ссылку после сброса устройств: %s", + refresh_error, + ) + await callback.message.edit_text( "✅ Устройства пользователя успешно сброшены", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index cecfdf22..e4cc7144 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -1,6 +1,7 @@ import base64 import json import logging +import math from datetime import datetime, timedelta from typing import Dict, List, Any, Tuple, Optional from urllib.parse import quote @@ -81,6 +82,27 @@ from app.utils.promo_offer import ( from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, format_additional_section, get_apps_for_device, get_device_name, get_step_description, logger from .countries import _get_available_countries + +def _format_cooldown_duration(seconds: int, language: str) -> str: + total_minutes = max(1, math.ceil(seconds / 60)) + days, remainder_minutes = divmod(total_minutes, 60 * 24) + hours, minutes = divmod(remainder_minutes, 60) + + language_code = (language or "ru").split("-")[0].lower() + if language_code == "en": + day_label, hour_label, minute_label = "d", "h", "m" + else: + day_label, hour_label, minute_label = "д", "ч", "м" + + parts: list[str] = [] + if days: + parts.append(f"{days}{day_label}") + if hours or days: + parts.append(f"{hours}{hour_label}") + parts.append(f"{minutes}{minute_label}") + + return " ".join(parts) + async def get_current_devices_detailed(db_user: User) -> dict: try: if not db_user.remnawave_uuid: @@ -772,6 +794,30 @@ async def handle_all_devices_reset_from_management( from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() + subscription = getattr(db_user, "subscription", None) + cooldown_remaining = ( + service.get_devices_reset_cooldown_remaining(subscription) + if subscription + else None + ) + + if cooldown_remaining: + remaining_seconds = int(cooldown_remaining.total_seconds()) + cooldown = settings.get_happ_cryptolink_reset_cooldown() + cooldown_seconds = int(cooldown.total_seconds()) if cooldown else remaining_seconds + + await callback.answer( + texts.t( + "DEVICE_RESET_COOLDOWN", + "⏳ Сброс устройств доступен раз в {cooldown}. Попробуйте через {remaining}.", + ).format( + cooldown=_format_cooldown_duration(cooldown_seconds, db_user.language), + remaining=_format_cooldown_duration(remaining_seconds, db_user.language), + ), + show_alert=True, + ) + return + async with service.get_api_client() as api: devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') @@ -819,6 +865,15 @@ async def handle_all_devices_reset_from_management( failed_count += 1 logger.warning(f"⚠️ У устройства нет HWID: {device}") + if success_count > 0: + try: + await service.refresh_happ_subscription_after_reset(db, db_user) + except Exception as refresh_error: + logger.warning( + "⚠️ Не удалось обновить Happ ссылку после сброса устройств: %s", + refresh_error, + ) + if success_count > 0: if failed_count == 0: await callback.message.edit_text( diff --git a/app/handlers/subscription/links.py b/app/handlers/subscription/links.py index a8842030..ce8365df 100644 --- a/app/handlers/subscription/links.py +++ b/app/handlers/subscription/links.py @@ -85,6 +85,13 @@ async def handle_connect_subscription( ): texts = get_texts(db_user.language) subscription = db_user.subscription + + if subscription: + try: + await db.refresh(subscription) + except Exception: + pass + subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() @@ -250,6 +257,13 @@ async def handle_open_subscription_link( ): texts = get_texts(db_user.language) subscription = db_user.subscription + + if subscription: + try: + await db.refresh(subscription) + except Exception: + pass + subscription_link = get_display_subscription_link(subscription) if not subscription_link: diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 02cf3d0f..e388999e 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -982,6 +982,7 @@ "DEVICE_RESET_ALL_DONE": "ℹ️ All devices have been reset", "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Couldn't reset devices\n\nPlease try again later or contact support.\n\nTotal devices: {total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ All devices have been reset!\n\n🔄 Reset: {count} devices\n📱 You can now reconnect your devices\n\n💡 Use the link from the 'My subscription' section to reconnect", + "DEVICE_RESET_COOLDOWN": "⏳ Device reset is available every {cooldown}. Try again in {remaining}.", "DEVICE_RESET_ERROR": "❌ Failed to reset the device", "DEVICE_RESET_ID_FAILED": "❌ Unable to get device ID", "DEVICE_RESET_INVALID_REQUEST": "❌ Error: invalid request", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index e1d2b5cb..de1f00c5 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -994,6 +994,7 @@ "DEVICE_RESET_ALL_DONE": "ℹ️ Все устройства сброшены", "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Не удалось сбросить устройства\n\nПопробуйте еще раз позже или обратитесь в техподдержку.\n\nВсего устройств: {total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ Все устройства успешно сброшены!\n\n🔄 Сброшено: {count} устройств\n📱 Теперь вы можете заново подключить свои устройства\n\n💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения", + "DEVICE_RESET_COOLDOWN": "⏳ Сброс устройств доступен раз в {cooldown}. Попробуйте через {remaining}.", "DEVICE_RESET_ERROR": "❌ Ошибка сброса устройства", "DEVICE_RESET_ID_FAILED": "❌ Не удалось получить ID устройства", "DEVICE_RESET_INVALID_REQUEST": "❌ Ошибка: некорректный запрос", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 69139498..4deacb1b 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -927,6 +927,7 @@ "DEVICE_RESET_ALL_DONE": "ℹ️ Всі пристрої скинуто", "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Не вдалося скинути пристрої\n\nСпробуйте ще раз пізніше або зверніться до техпідтримки.\n\nВсього пристроїв: {total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ Всі пристрої успішно скинуто!\n\n🔄 Скинуто: {count} пристроїв\n📱 Тепер ви можете заново підключити свої пристрої\n\n💡 Використовуйте посилання з розділу 'Моя підписка' для повторного підключення", + "DEVICE_RESET_COOLDOWN": "⏳ Скидання пристроїв доступне раз на {cooldown}. Спробуйте через {remaining}.", "DEVICE_RESET_ERROR": "❌ Помилка скидання пристрою", "DEVICE_RESET_ID_FAILED": "❌ Не вдалося отримати ID пристрою", "DEVICE_RESET_INVALID_REQUEST": "❌ Помилка: некоректний запит", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index a107bf81..0444d118 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -926,6 +926,7 @@ "DEVICE_RESET_ALL_DONE":"ℹ️所有设备已重置", "DEVICE_RESET_ALL_FAILED_MESSAGE":"❌重置设备失败\n\n请稍后再试或联系技术支持。\n\n总设备数:{total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE":"✅所有设备已成功重置!\n\n🔄已重置:{count}台设备\n📱您现在可以重新连接您的设备\n\n💡使用“我的订阅”部分中的链接重新连接", +"DEVICE_RESET_COOLDOWN":"⏳设备重置每隔{cooldown}可用一次。请在{remaining}后再试。", "DEVICE_RESET_ERROR":"❌重置设备失败", "DEVICE_RESET_ID_FAILED":"❌获取设备ID失败", "DEVICE_RESET_INVALID_REQUEST":"❌错误:请求无效", diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index c23cbda1..e654a29e 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -17,6 +17,7 @@ from sqlalchemy import and_, cast, delete, func, select, update, String from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession +from app.utils.happ_cryptolink_utils import generate_limited_happ_link from app.database.crud.user import ( create_user_no_commit, get_users_list, @@ -237,6 +238,49 @@ class RemnaWaveService: async with self.api as api: yield api + async def _build_happ_crypto_link( + self, + api: RemnaWaveAPI, + subscription: "Subscription", + base_subscription_url: Optional[str], + panel_crypto_link: Optional[str], + ) -> Optional[str]: + if not settings.is_happ_cryptolink_mode(): + return panel_crypto_link + + if not settings.is_happ_cryptolink_limited_links_enabled(): + return panel_crypto_link + + provider_code, auth_key = settings.get_happ_cryptolink_credentials() + if not provider_code or not auth_key: + logger.debug("⚙️ Нет данных для генерации лимитированной Happ ссылки") + return panel_crypto_link + + base_link = (base_subscription_url or "").strip() + if not base_link: + logger.warning("⚠️ Базовая ссылка подписки Happ отсутствует") + return panel_crypto_link + + install_limit = settings.get_happ_cryptolink_install_limit(getattr(subscription, "device_limit", None)) + if not install_limit: + logger.debug("⚙️ Лимит установок Happ не задан") + return panel_crypto_link + + limited_link = await generate_limited_happ_link( + base_link, + settings.get_happ_cryptolink_add_install_url(), + provider_code, + auth_key, + install_limit, + ) + + if not limited_link: + logger.warning("⚠️ Не удалось создать лимитированную Happ ссылку") + return panel_crypto_link + + encrypted_link = await api.encrypt_happ_crypto_link(limited_link) + return encrypted_link or limited_link + def _now_utc(self) -> datetime: """Возвращает текущее время в UTC без привязки к часовому поясу.""" return datetime.now(self._utc_timezone).replace(tzinfo=None) @@ -2007,6 +2051,79 @@ class RemnaWaveService: logger.error(f"Ошибка валидации данных пользователя: {e}") return False + def get_devices_reset_cooldown_remaining(self, subscription: "Subscription") -> Optional[timedelta]: + if not settings.is_happ_cryptolink_mode() or not settings.is_happ_cryptolink_limited_links_enabled(): + return None + + cooldown = settings.get_happ_cryptolink_reset_cooldown() + if not cooldown: + return None + + last_reset = getattr(subscription, "last_devices_reset_at", None) + if not last_reset: + return None + + now = datetime.utcnow() + next_allowed = last_reset + cooldown + if now >= next_allowed: + return None + + return next_allowed - now + + async def refresh_happ_subscription_after_reset( + self, + db: AsyncSession, + user: "User", + ) -> Optional[str]: + if not settings.is_happ_cryptolink_mode() or not settings.is_happ_cryptolink_limited_links_enabled(): + return None + + if not getattr(user, "remnawave_uuid", None): + logger.debug("⚙️ Нет RemnaWave UUID для обновления Happ ссылки") + return None + + subscription = getattr(user, "subscription", None) + if not subscription: + logger.debug("⚙️ У пользователя нет подписки для обновления Happ ссылки") + return None + + try: + async with self.get_api_client() as api: + revoked_user = await api.revoke_user_subscription(user.remnawave_uuid) + + subscription.remnawave_short_uuid = revoked_user.short_uuid + subscription.subscription_url = revoked_user.subscription_url + + crypto_link = await self._build_happ_crypto_link( + api, + subscription, + revoked_user.subscription_url, + revoked_user.happ_crypto_link, + ) + + subscription.subscription_crypto_link = crypto_link or revoked_user.happ_crypto_link + subscription.last_devices_reset_at = datetime.utcnow() + + await db.commit() + + logger.info( + "🔄 Обновлена Happ ссылка после сброса устройств пользователя %s", + getattr(user, "telegram_id", "?"), + ) + + return subscription.subscription_crypto_link + + except Exception as error: + logger.error( + "❌ Ошибка обновления Happ ссылки после сброса устройств: %s", + error, + ) + try: + await db.rollback() + except Exception: + pass + return None + async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool: try: logger.info(f"🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user.telegram_id}") diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index f7a29e2f..d325f651 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -20,6 +20,7 @@ from app.utils.pricing_utils import ( from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) +from app.utils.happ_cryptolink_utils import generate_limited_happ_link logger = logging.getLogger(__name__) @@ -160,6 +161,49 @@ class SubscriptionService: assert self.api is not None async with self.api as api: yield api + + async def _build_happ_crypto_link( + self, + api: RemnaWaveAPI, + subscription: Subscription, + base_subscription_url: Optional[str], + panel_crypto_link: Optional[str], + ) -> Optional[str]: + if not settings.is_happ_cryptolink_mode(): + return panel_crypto_link + + if not settings.is_happ_cryptolink_limited_links_enabled(): + return panel_crypto_link + + provider_code, auth_key = settings.get_happ_cryptolink_credentials() + if not provider_code or not auth_key: + logger.debug("⚙️ Данные для лимитированных ссылок Happ не заданы") + return panel_crypto_link + + base_link = (base_subscription_url or "").strip() + if not base_link: + logger.warning("⚠️ Базовая ссылка подписки для Happ отсутствует") + return panel_crypto_link + + install_limit = settings.get_happ_cryptolink_install_limit(subscription.device_limit) + if not install_limit: + logger.debug("⚙️ Лимит установок для Happ не задан") + return panel_crypto_link + + limited_link = await generate_limited_happ_link( + base_link, + settings.get_happ_cryptolink_add_install_url(), + provider_code, + auth_key, + install_limit, + ) + + if not limited_link: + logger.warning("⚠️ Не удалось сгенерировать лимитированную Happ ссылку") + return panel_crypto_link + + encrypted_link = await api.encrypt_happ_crypto_link(limited_link) + return encrypted_link or limited_link async def create_remnawave_user( self, @@ -266,7 +310,15 @@ class SubscriptionService: subscription.remnawave_short_uuid = updated_user.short_uuid subscription.subscription_url = updated_user.subscription_url - subscription.subscription_crypto_link = updated_user.happ_crypto_link + + crypto_link = await self._build_happ_crypto_link( + api, + subscription, + updated_user.subscription_url, + updated_user.happ_crypto_link, + ) + + subscription.subscription_crypto_link = crypto_link or updated_user.happ_crypto_link user.remnawave_uuid = updated_user.uuid await db.commit() @@ -348,7 +400,15 @@ class SubscriptionService: ) subscription.subscription_url = updated_user.subscription_url - subscription.subscription_crypto_link = updated_user.happ_crypto_link + + crypto_link = await self._build_happ_crypto_link( + api, + subscription, + updated_user.subscription_url, + updated_user.happ_crypto_link, + ) + + subscription.subscription_crypto_link = crypto_link or updated_user.happ_crypto_link await db.commit() status_text = "активным" if is_actually_active else "истёкшим" diff --git a/app/utils/happ_cryptolink_utils.py b/app/utils/happ_cryptolink_utils.py new file mode 100644 index 00000000..d808fa7d --- /dev/null +++ b/app/utils/happ_cryptolink_utils.py @@ -0,0 +1,80 @@ +from typing import Optional +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import aiohttp + + +def _append_query_param_to_fragment(fragment: str, param: str, value: str) -> str: + base_part, _, query_part = fragment.partition("?") + query_params = dict(parse_qsl(query_part, keep_blank_values=True)) + query_params[param] = value + + encoded_query = urlencode(query_params) + return f"{base_part}?{encoded_query}" if base_part else encoded_query + + +def append_install_code(base_link: str, install_code: str, param_name: str = "installid") -> str: + """Добавляет параметр installid в ссылку, сохраняя существующие параметры.""" + + if not install_code: + return base_link + + parsed = urlsplit(base_link) + + if parsed.fragment: + updated_fragment = _append_query_param_to_fragment(parsed.fragment, param_name, install_code) + return urlunsplit(( + parsed.scheme, + parsed.netloc, + parsed.path, + parsed.query, + updated_fragment, + )) + + query_params = dict(parse_qsl(parsed.query, keep_blank_values=True)) + query_params[param_name] = install_code + updated_query = urlencode(query_params) + + return urlunsplit(( + parsed.scheme, + parsed.netloc, + parsed.path, + updated_query, + parsed.fragment, + )) + + +async def generate_limited_happ_link( + base_link: str, + api_url: str, + provider_code: str, + auth_key: str, + install_limit: int, + *, + timeout_seconds: int = 15, +) -> Optional[str]: + if not base_link or install_limit <= 0: + return None + + timeout = aiohttp.ClientTimeout(total=timeout_seconds) + params = { + "provider_code": provider_code, + "auth_key": auth_key, + "install_limit": install_limit, + } + + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(api_url, params=params) as response: + data = await response.json() + except Exception: + return None + + if data.get("rc") != 1: + return None + + install_code = data.get("install_code") or data.get("installCode") + if not install_code: + return None + + return append_install_code(base_link, install_code) diff --git a/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py b/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py new file mode 100644 index 00000000..262dd488 --- /dev/null +++ b/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py @@ -0,0 +1,21 @@ +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "f6c5c60dba2e" +down_revision: Union[str, None] = "e3c1e0b5b4a7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "subscriptions", + sa.Column("last_devices_reset_at", sa.DateTime(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("subscriptions", "last_devices_reset_at") From f8ef2b9f5af6848e246705cae2cd767382b00449 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 15 Dec 2025 11:12:14 +0300 Subject: [PATCH 6/7] Revert "Add limited Happ cryptolink support" --- .env.example | 6 - app/config.py | 52 +------- app/database/models.py | 4 +- app/database/universal_migration.py | 37 ------ app/handlers/admin/users.py | 10 +- app/handlers/subscription/devices.py | 55 -------- app/handlers/subscription/links.py | 14 --- app/localization/locales/en.json | 1 - app/localization/locales/ru.json | 1 - app/localization/locales/ua.json | 1 - app/localization/locales/zh.json | 1 - app/services/remnawave_service.py | 117 ------------------ app/services/subscription_service.py | 64 +--------- app/utils/happ_cryptolink_utils.py | 80 ------------ ...6c5c60dba2e_add_devices_reset_timestamp.py | 21 ---- 15 files changed, 5 insertions(+), 459 deletions(-) delete mode 100644 app/utils/happ_cryptolink_utils.py delete mode 100644 migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py diff --git a/.env.example b/.env.example index ad38c3da..0fc1e422 100644 --- a/.env.example +++ b/.env.example @@ -396,12 +396,6 @@ MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подкл # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false -HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED=false -HAPP_CRYPTOLINK_PROVIDER_CODE= -HAPP_CRYPTOLINK_AUTH_KEY= -HAPP_CRYPTOLINK_INSTALL_LIMIT=0 -HAPP_CRYPTOLINK_API_BASE_URL=https://api.happ-proxy.com -HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES=0 HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_MACOS= diff --git a/app/config.py b/app/config.py index 438a84ed..9a3ee4e9 100644 --- a/app/config.py +++ b/app/config.py @@ -6,7 +6,7 @@ import os import re import html from collections import defaultdict -from datetime import time, timedelta +from datetime import time from typing import Dict, List, Optional, Union from urllib.parse import urlparse from zoneinfo import ZoneInfo @@ -339,12 +339,6 @@ class Settings(BaseSettings): MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None - HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED: bool = False - HAPP_CRYPTOLINK_PROVIDER_CODE: Optional[str] = None - HAPP_CRYPTOLINK_AUTH_KEY: Optional[str] = None - HAPP_CRYPTOLINK_INSTALL_LIMIT: int = 0 - HAPP_CRYPTOLINK_API_BASE_URL: str = "https://api.happ-proxy.com" - HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES: int = 0 HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None @@ -1194,50 +1188,6 @@ class Settings(BaseSettings): def is_happ_download_button_enabled(self) -> bool: return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED - def is_happ_cryptolink_limited_links_enabled(self) -> bool: - if not self.is_happ_cryptolink_mode(): - return False - - if not self.HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED: - return False - - provider_code, auth_key = self.get_happ_cryptolink_credentials() - return bool(provider_code and auth_key) - - def get_happ_cryptolink_credentials(self) -> tuple[Optional[str], Optional[str]]: - provider_code = (self.HAPP_CRYPTOLINK_PROVIDER_CODE or "").strip() - auth_key = (self.HAPP_CRYPTOLINK_AUTH_KEY or "").strip() - return provider_code or None, auth_key or None - - def get_happ_cryptolink_install_limit(self, fallback_limit: Optional[int] = None) -> Optional[int]: - try: - limit = int(self.HAPP_CRYPTOLINK_INSTALL_LIMIT) - except (TypeError, ValueError): - limit = 0 - - if limit <= 0: - limit = fallback_limit or 0 - - if limit <= 0: - return None - - return limit - - def get_happ_cryptolink_add_install_url(self) -> str: - base_url = (self.HAPP_CRYPTOLINK_API_BASE_URL or "").rstrip("/") - return f"{base_url}/api/add-install" - - def get_happ_cryptolink_reset_cooldown(self) -> Optional[timedelta]: - try: - minutes = int(self.HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES) - except (TypeError, ValueError): - minutes = 0 - - if minutes <= 0: - return None - - return timedelta(minutes=max(1, minutes)) - def should_hide_subscription_link(self) -> bool: """Returns True when subscription links must be hidden from the interface.""" diff --git a/app/database/models.py b/app/database/models.py index d6a50988..41887159 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -682,9 +682,7 @@ class Subscription(Base): subscription_crypto_link = Column(String, nullable=True) device_limit = Column(Integer, default=1) - - last_devices_reset_at = Column(DateTime, nullable=True) - + connected_squads = Column(JSON, default=list) autopay_enabled = Column(Boolean, default=False) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index c23cbbd0..058e3eab 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -3120,33 +3120,6 @@ async def add_subscription_crypto_link_column() -> bool: return False -async def add_last_devices_reset_column() -> bool: - column_exists = await check_column_exists('subscriptions', 'last_devices_reset_at') - if column_exists: - logger.info("ℹ️ Колонка last_devices_reset_at уже существует") - return True - - try: - async with engine.begin() as conn: - db_type = await get_database_type() - - if db_type == 'sqlite': - await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at DATETIME")) - elif db_type == 'postgresql': - await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at TIMESTAMP")) - elif db_type == 'mysql': - await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at DATETIME")) - else: - logger.error(f"Неподдерживаемый тип БД для добавления last_devices_reset_at: {db_type}") - return False - - logger.info("✅ Добавлена колонка last_devices_reset_at в таблицу subscriptions") - return True - except Exception as e: - logger.error(f"Ошибка добавления колонки last_devices_reset_at: {e}") - return False - - async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: @@ -4568,13 +4541,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link") - logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ LAST_DEVICES_RESET_AT ДЛЯ ПОДПИСОК ===") - devices_reset_column_added = await add_last_devices_reset_column() - if devices_reset_column_added: - logger.info("✅ Колонка last_devices_reset_at готова") - else: - logger.warning("⚠️ Проблемы с добавлением колонки last_devices_reset_at") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===") try: async with engine.begin() as conn: @@ -4736,7 +4702,6 @@ async def check_migration_status(): "subscription_duplicates": False, "subscription_conversions_table": False, "subscription_events_table": False, - "subscription_last_devices_reset_column": False, "promo_groups_table": False, "server_promo_groups_table": False, "server_squads_trial_column": False, @@ -4808,7 +4773,6 @@ async def check_migration_status(): status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at') status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') - status["subscription_last_devices_reset_column"] = await check_column_exists('subscriptions', 'last_devices_reset_at') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -4857,7 +4821,6 @@ async def check_migration_status(): "users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей", "users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", - "subscription_last_devices_reset_column": "Колонка last_devices_reset_at в subscriptions", "discount_offers_table": "Таблица discount_offers", "discount_offers_effect_column": "Колонка effect_type в discount_offers", "discount_offers_extra_column": "Колонка extra_data в discount_offers", diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index f5c23fa2..5c145416 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -4004,16 +4004,8 @@ async def reset_user_devices( remnawave_service = RemnaWaveService() async with remnawave_service.get_api_client() as api: success = await api.reset_user_devices(user.remnawave_uuid) - + if success: - try: - await remnawave_service.refresh_happ_subscription_after_reset(db, user) - except Exception as refresh_error: - logger.warning( - "⚠️ Не удалось обновить Happ ссылку после сброса устройств: %s", - refresh_error, - ) - await callback.message.edit_text( "✅ Устройства пользователя успешно сброшены", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index e4cc7144..cecfdf22 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -1,7 +1,6 @@ import base64 import json import logging -import math from datetime import datetime, timedelta from typing import Dict, List, Any, Tuple, Optional from urllib.parse import quote @@ -82,27 +81,6 @@ from app.utils.promo_offer import ( from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, format_additional_section, get_apps_for_device, get_device_name, get_step_description, logger from .countries import _get_available_countries - -def _format_cooldown_duration(seconds: int, language: str) -> str: - total_minutes = max(1, math.ceil(seconds / 60)) - days, remainder_minutes = divmod(total_minutes, 60 * 24) - hours, minutes = divmod(remainder_minutes, 60) - - language_code = (language or "ru").split("-")[0].lower() - if language_code == "en": - day_label, hour_label, minute_label = "d", "h", "m" - else: - day_label, hour_label, minute_label = "д", "ч", "м" - - parts: list[str] = [] - if days: - parts.append(f"{days}{day_label}") - if hours or days: - parts.append(f"{hours}{hour_label}") - parts.append(f"{minutes}{minute_label}") - - return " ".join(parts) - async def get_current_devices_detailed(db_user: User) -> dict: try: if not db_user.remnawave_uuid: @@ -794,30 +772,6 @@ async def handle_all_devices_reset_from_management( from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - subscription = getattr(db_user, "subscription", None) - cooldown_remaining = ( - service.get_devices_reset_cooldown_remaining(subscription) - if subscription - else None - ) - - if cooldown_remaining: - remaining_seconds = int(cooldown_remaining.total_seconds()) - cooldown = settings.get_happ_cryptolink_reset_cooldown() - cooldown_seconds = int(cooldown.total_seconds()) if cooldown else remaining_seconds - - await callback.answer( - texts.t( - "DEVICE_RESET_COOLDOWN", - "⏳ Сброс устройств доступен раз в {cooldown}. Попробуйте через {remaining}.", - ).format( - cooldown=_format_cooldown_duration(cooldown_seconds, db_user.language), - remaining=_format_cooldown_duration(remaining_seconds, db_user.language), - ), - show_alert=True, - ) - return - async with service.get_api_client() as api: devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') @@ -865,15 +819,6 @@ async def handle_all_devices_reset_from_management( failed_count += 1 logger.warning(f"⚠️ У устройства нет HWID: {device}") - if success_count > 0: - try: - await service.refresh_happ_subscription_after_reset(db, db_user) - except Exception as refresh_error: - logger.warning( - "⚠️ Не удалось обновить Happ ссылку после сброса устройств: %s", - refresh_error, - ) - if success_count > 0: if failed_count == 0: await callback.message.edit_text( diff --git a/app/handlers/subscription/links.py b/app/handlers/subscription/links.py index ce8365df..a8842030 100644 --- a/app/handlers/subscription/links.py +++ b/app/handlers/subscription/links.py @@ -85,13 +85,6 @@ async def handle_connect_subscription( ): texts = get_texts(db_user.language) subscription = db_user.subscription - - if subscription: - try: - await db.refresh(subscription) - except Exception: - pass - subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() @@ -257,13 +250,6 @@ async def handle_open_subscription_link( ): texts = get_texts(db_user.language) subscription = db_user.subscription - - if subscription: - try: - await db.refresh(subscription) - except Exception: - pass - subscription_link = get_display_subscription_link(subscription) if not subscription_link: diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index e388999e..02cf3d0f 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -982,7 +982,6 @@ "DEVICE_RESET_ALL_DONE": "ℹ️ All devices have been reset", "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Couldn't reset devices\n\nPlease try again later or contact support.\n\nTotal devices: {total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ All devices have been reset!\n\n🔄 Reset: {count} devices\n📱 You can now reconnect your devices\n\n💡 Use the link from the 'My subscription' section to reconnect", - "DEVICE_RESET_COOLDOWN": "⏳ Device reset is available every {cooldown}. Try again in {remaining}.", "DEVICE_RESET_ERROR": "❌ Failed to reset the device", "DEVICE_RESET_ID_FAILED": "❌ Unable to get device ID", "DEVICE_RESET_INVALID_REQUEST": "❌ Error: invalid request", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index de1f00c5..e1d2b5cb 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -994,7 +994,6 @@ "DEVICE_RESET_ALL_DONE": "ℹ️ Все устройства сброшены", "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Не удалось сбросить устройства\n\nПопробуйте еще раз позже или обратитесь в техподдержку.\n\nВсего устройств: {total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ Все устройства успешно сброшены!\n\n🔄 Сброшено: {count} устройств\n📱 Теперь вы можете заново подключить свои устройства\n\n💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения", - "DEVICE_RESET_COOLDOWN": "⏳ Сброс устройств доступен раз в {cooldown}. Попробуйте через {remaining}.", "DEVICE_RESET_ERROR": "❌ Ошибка сброса устройства", "DEVICE_RESET_ID_FAILED": "❌ Не удалось получить ID устройства", "DEVICE_RESET_INVALID_REQUEST": "❌ Ошибка: некорректный запрос", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 4deacb1b..69139498 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -927,7 +927,6 @@ "DEVICE_RESET_ALL_DONE": "ℹ️ Всі пристрої скинуто", "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Не вдалося скинути пристрої\n\nСпробуйте ще раз пізніше або зверніться до техпідтримки.\n\nВсього пристроїв: {total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ Всі пристрої успішно скинуто!\n\n🔄 Скинуто: {count} пристроїв\n📱 Тепер ви можете заново підключити свої пристрої\n\n💡 Використовуйте посилання з розділу 'Моя підписка' для повторного підключення", - "DEVICE_RESET_COOLDOWN": "⏳ Скидання пристроїв доступне раз на {cooldown}. Спробуйте через {remaining}.", "DEVICE_RESET_ERROR": "❌ Помилка скидання пристрою", "DEVICE_RESET_ID_FAILED": "❌ Не вдалося отримати ID пристрою", "DEVICE_RESET_INVALID_REQUEST": "❌ Помилка: некоректний запит", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 0444d118..a107bf81 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -926,7 +926,6 @@ "DEVICE_RESET_ALL_DONE":"ℹ️所有设备已重置", "DEVICE_RESET_ALL_FAILED_MESSAGE":"❌重置设备失败\n\n请稍后再试或联系技术支持。\n\n总设备数:{total}", "DEVICE_RESET_ALL_SUCCESS_MESSAGE":"✅所有设备已成功重置!\n\n🔄已重置:{count}台设备\n📱您现在可以重新连接您的设备\n\n💡使用“我的订阅”部分中的链接重新连接", -"DEVICE_RESET_COOLDOWN":"⏳设备重置每隔{cooldown}可用一次。请在{remaining}后再试。", "DEVICE_RESET_ERROR":"❌重置设备失败", "DEVICE_RESET_ID_FAILED":"❌获取设备ID失败", "DEVICE_RESET_INVALID_REQUEST":"❌错误:请求无效", diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index e654a29e..c23cbda1 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -17,7 +17,6 @@ from sqlalchemy import and_, cast, delete, func, select, update, String from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession -from app.utils.happ_cryptolink_utils import generate_limited_happ_link from app.database.crud.user import ( create_user_no_commit, get_users_list, @@ -238,49 +237,6 @@ class RemnaWaveService: async with self.api as api: yield api - async def _build_happ_crypto_link( - self, - api: RemnaWaveAPI, - subscription: "Subscription", - base_subscription_url: Optional[str], - panel_crypto_link: Optional[str], - ) -> Optional[str]: - if not settings.is_happ_cryptolink_mode(): - return panel_crypto_link - - if not settings.is_happ_cryptolink_limited_links_enabled(): - return panel_crypto_link - - provider_code, auth_key = settings.get_happ_cryptolink_credentials() - if not provider_code or not auth_key: - logger.debug("⚙️ Нет данных для генерации лимитированной Happ ссылки") - return panel_crypto_link - - base_link = (base_subscription_url or "").strip() - if not base_link: - logger.warning("⚠️ Базовая ссылка подписки Happ отсутствует") - return panel_crypto_link - - install_limit = settings.get_happ_cryptolink_install_limit(getattr(subscription, "device_limit", None)) - if not install_limit: - logger.debug("⚙️ Лимит установок Happ не задан") - return panel_crypto_link - - limited_link = await generate_limited_happ_link( - base_link, - settings.get_happ_cryptolink_add_install_url(), - provider_code, - auth_key, - install_limit, - ) - - if not limited_link: - logger.warning("⚠️ Не удалось создать лимитированную Happ ссылку") - return panel_crypto_link - - encrypted_link = await api.encrypt_happ_crypto_link(limited_link) - return encrypted_link or limited_link - def _now_utc(self) -> datetime: """Возвращает текущее время в UTC без привязки к часовому поясу.""" return datetime.now(self._utc_timezone).replace(tzinfo=None) @@ -2051,79 +2007,6 @@ class RemnaWaveService: logger.error(f"Ошибка валидации данных пользователя: {e}") return False - def get_devices_reset_cooldown_remaining(self, subscription: "Subscription") -> Optional[timedelta]: - if not settings.is_happ_cryptolink_mode() or not settings.is_happ_cryptolink_limited_links_enabled(): - return None - - cooldown = settings.get_happ_cryptolink_reset_cooldown() - if not cooldown: - return None - - last_reset = getattr(subscription, "last_devices_reset_at", None) - if not last_reset: - return None - - now = datetime.utcnow() - next_allowed = last_reset + cooldown - if now >= next_allowed: - return None - - return next_allowed - now - - async def refresh_happ_subscription_after_reset( - self, - db: AsyncSession, - user: "User", - ) -> Optional[str]: - if not settings.is_happ_cryptolink_mode() or not settings.is_happ_cryptolink_limited_links_enabled(): - return None - - if not getattr(user, "remnawave_uuid", None): - logger.debug("⚙️ Нет RemnaWave UUID для обновления Happ ссылки") - return None - - subscription = getattr(user, "subscription", None) - if not subscription: - logger.debug("⚙️ У пользователя нет подписки для обновления Happ ссылки") - return None - - try: - async with self.get_api_client() as api: - revoked_user = await api.revoke_user_subscription(user.remnawave_uuid) - - subscription.remnawave_short_uuid = revoked_user.short_uuid - subscription.subscription_url = revoked_user.subscription_url - - crypto_link = await self._build_happ_crypto_link( - api, - subscription, - revoked_user.subscription_url, - revoked_user.happ_crypto_link, - ) - - subscription.subscription_crypto_link = crypto_link or revoked_user.happ_crypto_link - subscription.last_devices_reset_at = datetime.utcnow() - - await db.commit() - - logger.info( - "🔄 Обновлена Happ ссылка после сброса устройств пользователя %s", - getattr(user, "telegram_id", "?"), - ) - - return subscription.subscription_crypto_link - - except Exception as error: - logger.error( - "❌ Ошибка обновления Happ ссылки после сброса устройств: %s", - error, - ) - try: - await db.rollback() - except Exception: - pass - return None - async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool: try: logger.info(f"🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user.telegram_id}") diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index d325f651..f7a29e2f 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -20,7 +20,6 @@ from app.utils.pricing_utils import ( from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) -from app.utils.happ_cryptolink_utils import generate_limited_happ_link logger = logging.getLogger(__name__) @@ -161,49 +160,6 @@ class SubscriptionService: assert self.api is not None async with self.api as api: yield api - - async def _build_happ_crypto_link( - self, - api: RemnaWaveAPI, - subscription: Subscription, - base_subscription_url: Optional[str], - panel_crypto_link: Optional[str], - ) -> Optional[str]: - if not settings.is_happ_cryptolink_mode(): - return panel_crypto_link - - if not settings.is_happ_cryptolink_limited_links_enabled(): - return panel_crypto_link - - provider_code, auth_key = settings.get_happ_cryptolink_credentials() - if not provider_code or not auth_key: - logger.debug("⚙️ Данные для лимитированных ссылок Happ не заданы") - return panel_crypto_link - - base_link = (base_subscription_url or "").strip() - if not base_link: - logger.warning("⚠️ Базовая ссылка подписки для Happ отсутствует") - return panel_crypto_link - - install_limit = settings.get_happ_cryptolink_install_limit(subscription.device_limit) - if not install_limit: - logger.debug("⚙️ Лимит установок для Happ не задан") - return panel_crypto_link - - limited_link = await generate_limited_happ_link( - base_link, - settings.get_happ_cryptolink_add_install_url(), - provider_code, - auth_key, - install_limit, - ) - - if not limited_link: - logger.warning("⚠️ Не удалось сгенерировать лимитированную Happ ссылку") - return panel_crypto_link - - encrypted_link = await api.encrypt_happ_crypto_link(limited_link) - return encrypted_link or limited_link async def create_remnawave_user( self, @@ -310,15 +266,7 @@ class SubscriptionService: subscription.remnawave_short_uuid = updated_user.short_uuid subscription.subscription_url = updated_user.subscription_url - - crypto_link = await self._build_happ_crypto_link( - api, - subscription, - updated_user.subscription_url, - updated_user.happ_crypto_link, - ) - - subscription.subscription_crypto_link = crypto_link or updated_user.happ_crypto_link + subscription.subscription_crypto_link = updated_user.happ_crypto_link user.remnawave_uuid = updated_user.uuid await db.commit() @@ -400,15 +348,7 @@ class SubscriptionService: ) subscription.subscription_url = updated_user.subscription_url - - crypto_link = await self._build_happ_crypto_link( - api, - subscription, - updated_user.subscription_url, - updated_user.happ_crypto_link, - ) - - subscription.subscription_crypto_link = crypto_link or updated_user.happ_crypto_link + subscription.subscription_crypto_link = updated_user.happ_crypto_link await db.commit() status_text = "активным" if is_actually_active else "истёкшим" diff --git a/app/utils/happ_cryptolink_utils.py b/app/utils/happ_cryptolink_utils.py deleted file mode 100644 index d808fa7d..00000000 --- a/app/utils/happ_cryptolink_utils.py +++ /dev/null @@ -1,80 +0,0 @@ -from typing import Optional -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit - -import aiohttp - - -def _append_query_param_to_fragment(fragment: str, param: str, value: str) -> str: - base_part, _, query_part = fragment.partition("?") - query_params = dict(parse_qsl(query_part, keep_blank_values=True)) - query_params[param] = value - - encoded_query = urlencode(query_params) - return f"{base_part}?{encoded_query}" if base_part else encoded_query - - -def append_install_code(base_link: str, install_code: str, param_name: str = "installid") -> str: - """Добавляет параметр installid в ссылку, сохраняя существующие параметры.""" - - if not install_code: - return base_link - - parsed = urlsplit(base_link) - - if parsed.fragment: - updated_fragment = _append_query_param_to_fragment(parsed.fragment, param_name, install_code) - return urlunsplit(( - parsed.scheme, - parsed.netloc, - parsed.path, - parsed.query, - updated_fragment, - )) - - query_params = dict(parse_qsl(parsed.query, keep_blank_values=True)) - query_params[param_name] = install_code - updated_query = urlencode(query_params) - - return urlunsplit(( - parsed.scheme, - parsed.netloc, - parsed.path, - updated_query, - parsed.fragment, - )) - - -async def generate_limited_happ_link( - base_link: str, - api_url: str, - provider_code: str, - auth_key: str, - install_limit: int, - *, - timeout_seconds: int = 15, -) -> Optional[str]: - if not base_link or install_limit <= 0: - return None - - timeout = aiohttp.ClientTimeout(total=timeout_seconds) - params = { - "provider_code": provider_code, - "auth_key": auth_key, - "install_limit": install_limit, - } - - try: - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get(api_url, params=params) as response: - data = await response.json() - except Exception: - return None - - if data.get("rc") != 1: - return None - - install_code = data.get("install_code") or data.get("installCode") - if not install_code: - return None - - return append_install_code(base_link, install_code) diff --git a/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py b/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py deleted file mode 100644 index 262dd488..00000000 --- a/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision: str = "f6c5c60dba2e" -down_revision: Union[str, None] = "e3c1e0b5b4a7" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column( - "subscriptions", - sa.Column("last_devices_reset_at", sa.DateTime(), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("subscriptions", "last_devices_reset_at") From 7b60be1ec73e1d3ae44ca13d2ad93c59dc0b69f2 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 18 Dec 2025 03:04:13 +0300 Subject: [PATCH 7/7] Add toggle for trial deactivation on channel unsubscribe --- .env.example | 1 + app/config.py | 1 + app/middlewares/channel_checker.py | 9 ++++++++- app/services/monitoring_service.py | 6 ++++++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index f5fb2eb5..52eaa9dd 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,7 @@ SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланс CHANNEL_SUB_ID= # Опционально ID твоего канала (-100) CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал CHANNEL_LINK= # Опционально ссылка на канал +CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала # ===== DATABASE CONFIGURATION ===== # Режим базы данных: "auto", "postgresql", "sqlite" diff --git a/app/config.py b/app/config.py index 28c6779c..b3105f19 100644 --- a/app/config.py +++ b/app/config.py @@ -54,6 +54,7 @@ class Settings(BaseSettings): CHANNEL_SUB_ID: Optional[str] = None CHANNEL_LINK: Optional[str] = None CHANNEL_IS_REQUIRED_SUB: bool = False + CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True DATABASE_URL: Optional[str] = None diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index 9ec22d56..74d6dc02 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -108,7 +108,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): elif member.status in self.BAD_MEMBER_STATUS: logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})") - if telegram_id: + if telegram_id and settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: await self._deactivate_trial_subscription(telegram_id) await self._capture_start_payload(state, event, bot) @@ -254,6 +254,13 @@ class ChannelCheckerMiddleware(BaseMiddleware): break async def _deactivate_trial_subscription(self, telegram_id: int) -> None: + if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: + logger.debug( + "ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено", + telegram_id, + ) + return + async for db in get_db(): try: user = await get_user_by_telegram_id(db, telegram_id) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 9a0f6f95..83717ff1 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -507,6 +507,12 @@ class MonitoringService: if not settings.CHANNEL_IS_REQUIRED_SUB: return + if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: + logger.debug( + "ℹ️ Проверка отписок от канала отключена — деактивация триальных подписок не требуется" + ) + return + channel_id = settings.CHANNEL_SUB_ID if not channel_id: return