diff --git a/.env.example b/.env.example index 9fb5389..271b9d1 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,8 @@ TERMS_OF_SERVICE_URL=https://example.com/tos # SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app START_COMMAND_DESCRIPTION= # Description of the /start command DISABLE_WELCOME_MESSAGE= # Disable the welcome message +MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu +USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited) # Required channel subscription REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join diff --git a/README.md b/README.md index a29c948..5f2a2e0 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ ### Для пользователей: - **Регистрация и выбор языка:** Поддержка русского и английского языков. - **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию. +- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`). - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. @@ -66,6 +67,7 @@ | `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` | | `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` | | `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` | + | `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` | | `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` | | `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` | @@ -114,6 +116,9 @@ | `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. | | `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. | | `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). | + | `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). | + + > Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 3299149..2d7c4de 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -173,12 +173,27 @@ async def my_subscription_command_handler( ) ]) - prepend_rows.append([ - InlineKeyboardButton( - text=get_text("devices_button"), - callback_data="main_action:my_devices", + if settings.MY_DEVICES_SECTION_ENABLED: + max_devices_value = active.get("max_devices") + max_devices_display = get_text("devices_unlimited_label") + if max_devices_value not in (None, 0): + try: + max_devices_int = int(max_devices_value) + if max_devices_int >= 0: + max_devices_display = str(max_devices_int) + except (TypeError, ValueError): + max_devices_display = str(max_devices_value) + devices_button_text = get_text( + "devices_button", + current_devices="?", + max_devices=max_devices_display, ) - ]) + prepend_rows.append([ + InlineKeyboardButton( + text=devices_button_text, + callback_data="main_action:my_devices", + ) + ]) # 2) Auto-renew toggle (if supported and not tribute) if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): @@ -243,18 +258,52 @@ async def my_devices_command_handler( await event.answer(get_text("error_occurred_try_again")) return + if not settings.MY_DEVICES_SECTION_ENABLED: + if isinstance(event, types.CallbackQuery): + try: + await event.answer(get_text("my_devices_feature_disabled"), show_alert=True) + except Exception: + pass + else: + await target.answer(get_text("my_devices_feature_disabled")) + return + # TODO: context? active = await subscription_service.get_active_subscription_details(session, event.from_user.id) + if not active or not active.get("user_id"): + message = get_text("subscription_not_active") + if isinstance(event, types.CallbackQuery): + try: + await event.answer(message, show_alert=True) + except Exception: + pass + else: + await target.answer(message) + return devices = await panel_service.get_user_devices(active.get("user_id")) if active else None if not devices: - await target.answer(get_text("no_devices_found")) + if isinstance(event, types.CallbackQuery): + try: + await event.answer(get_text("no_devices_found"), show_alert=True) + except Exception: + pass + else: + await target.answer(get_text("no_devices_found")) return - max_devices = active.get("max_devices") + max_devices_value = active.get("max_devices") + max_devices_display = get_text("devices_unlimited_label") + if max_devices_value not in (None, 0): + try: + max_devices_int = int(max_devices_value) + if max_devices_int >= 0: + max_devices_display = str(max_devices_int) + except (TypeError, ValueError): + max_devices_display = str(max_devices_value) if not devices or not devices.get('devices') or len(devices.get('devices')) == 0: - text = get_text("no_devices_details_found_message", max_devices=max_devices) + text = get_text("no_devices_details_found_message", max_devices=max_devices_display) else: devices_list = [] current_devices = len(devices.get('devices') or []) @@ -270,7 +319,7 @@ async def my_devices_command_handler( device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid) devices_list.append(device_details) - text = get_text("my_devices_details", devices="\n\n".join(devices_list), current_devices=current_devices, max_devices=max_devices) + text = get_text("my_devices_details", devices="\n\n".join(devices_list), current_devices=current_devices, max_devices=max_devices_display) base_markup = get_back_to_main_menu_markup(current_lang, i18n, callback_data="main_action:my_subscription") kb = base_markup.inline_keyboard @@ -311,6 +360,13 @@ async def disconnect_device_handler( i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + if not settings.MY_DEVICES_SECTION_ENABLED: + try: + await callback.answer(get_text("my_devices_feature_disabled"), show_alert=True) + except Exception: + pass + return + try: _, hwid = callback.data.split(":", 1) except Exception: @@ -480,5 +536,3 @@ async def connect_command_handler( ): logging.info(f"User {message.from_user.id} used /connect command.") await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot) - - diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index b26a84c..60586d0 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -337,6 +337,7 @@ class PanelApiService: default_expire_days: int = 1, default_traffic_limit_bytes: int = 0, default_traffic_limit_strategy: str = "NO_RESET", + hwid_device_limit: Optional[int] = None, specific_squad_uuids: Optional[List[str]] = None, description: Optional[str] = None, tag: Optional[str] = None, @@ -368,6 +369,18 @@ class PanelApiService: "trafficLimitStrategy": default_traffic_limit_strategy.upper(), "trafficLimitBytes": default_traffic_limit_bytes, } + hwid_limit_value = hwid_device_limit + if hwid_limit_value is None: + hwid_limit_value = self.settings.USER_HWID_DEVICE_LIMIT + if hwid_limit_value is not None: + try: + hwid_limit_int = int(hwid_limit_value) + if hwid_limit_int >= 0: + payload["hwidDeviceLimit"] = hwid_limit_int + except (TypeError, ValueError): + logging.warning( + f"Ignoring invalid HWID device limit '{hwid_limit_value}' while creating panel user '{username_on_panel}'." + ) if specific_squad_uuids: payload["activeInternalSquads"] = specific_squad_uuids if telegram_id is not None: payload["telegramId"] = telegram_id diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 1e784a1..73fe993 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -739,6 +739,9 @@ class SubscriptionService: if panel_user_data.get("expireAt") else None ) + hwid_limit = panel_user_data.get("hwidDeviceLimit") + if hwid_limit is None: + hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT return { "user_id": panel_user_data.get("uuid"), @@ -749,7 +752,7 @@ class SubscriptionService: "traffic_used_bytes": panel_user_data.get("usedTrafficBytes"), "user_bot_username": db_user.username, "is_panel_data": True, - "max_devices": panel_user_data.get("hwidDeviceLimit"), + "max_devices": hwid_limit, } async def get_subscriptions_ending_soon( diff --git a/config/settings.py b/config/settings.py index d1a4273..f7a86a9 100644 --- a/config/settings.py +++ b/config/settings.py @@ -137,6 +137,15 @@ class Settings(BaseSettings): START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None) DISABLE_WELCOME_MESSAGE: bool = Field(default=False, description="Disable welcome message on /start command") + + MY_DEVICES_SECTION_ENABLED: bool = Field( + default=False, + description="Enable the My Devices section in the subscription menu" + ) + USER_HWID_DEVICE_LIMIT: Optional[int] = Field( + default=None, + description="Default hardware device limit for panel users (0 = unlimited)" + ) # Inline mode thumbnail URLs INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png") @@ -366,6 +375,15 @@ class Settings(BaseSettings): return None return v + @field_validator('USER_HWID_DEVICE_LIMIT', mode='before') + @classmethod + def validate_optional_int(cls, v): + if isinstance(v, str): + v = v.strip() + if not v: + return None + return v + # Notification types LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations") LOG_PAYMENTS: bool = Field(default=True, description="Send notifications for successful payments") diff --git a/locales/en.json b/locales/en.json index 8c72f3b..a911e22 100644 --- a/locales/en.json +++ b/locales/en.json @@ -51,6 +51,8 @@ "disconnect_device_button": "🚫 Disconnect {hwid} ({index})", "device_disconnected": "Device disconnected successfully.", "no_devices_found": "Error loading device list.", + "devices_unlimited_label": "Unlimited", + "my_devices_feature_disabled": "The My Devices section is currently unavailable.", "payment_description_subscription": "Subscription payment for {months} mo.", "payment_link_message": "To pay for {months} mo. subscription, click the button below:", diff --git a/locales/ru.json b/locales/ru.json index 4b9e0b0..c490ce3 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -43,13 +43,15 @@ "pay_with_stars_button": "🌟 Звезды Telegram", "connect_button": "🔗 Подключиться", - "devices_button": "📱 Мои устройства", + "devices_button": "📱 Мои устройства ({current_devices}/{max_devices})", "my_devices_details": "📱 Список ваших устройств ({current_devices}/{max_devices})\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n
Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.
", "no_devices_details_found_message": "📱 Список ваших устройств\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.", "device_details": "{index}) {device_model} ({platform} {os_version})\nДата подключения: {created_at_str}\nUser Agent: {user_agent}\nHWID: {hwid}", "disconnect_device_button": "🚫 Отключить {hwid} ({index})", "device_disconnected": "Устройство успешно отключено.", "no_devices_found": "Ошибка загрузки списка устройств.", + "devices_unlimited_label": "Без ограничений", + "my_devices_feature_disabled": "Раздел \"Мои устройства\" сейчас недоступен.", "cancel_button": "❌ Отмена", "payment_description_subscription": "Оплата подписки на {months} мес.",