From 8f9484b6ece1bf6911a6f33fa53b9a0aab651925 Mon Sep 17 00:00:00 2001 From: Kirill Gladkikh Date: Sat, 18 Oct 2025 02:27:19 +0300 Subject: [PATCH] Add 'My Devices' feature to user subscription management - Implemented a new command handler for displaying user devices. - Added functionality to disconnect devices from the user's account. - Updated subscription service to retrieve and manage device information. - Enhanced inline keyboard to include device management options. - Added new translations for device-related messages in English and Russian locales. --- bot/handlers/user/start.py | 25 +++-- bot/handlers/user/subscription/__init__.py | 2 +- bot/handlers/user/subscription/core.py | 121 +++++++++++++++++++++ bot/keyboards/inline/user_keyboards.py | 11 +- bot/services/panel_api_service.py | 37 +++++-- bot/services/subscription_service.py | 8 +- locales/en.json | 9 ++ locales/ru.json | 9 ++ 8 files changed, 197 insertions(+), 25 deletions(-) diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index 52f902b..01ec0d2 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -246,52 +246,52 @@ async def start_command_handler(message: types.Message, # Send welcome message if not disabled if not settings.DISABLE_WELCOME_MESSAGE: await message.answer(_(key="welcome", user_name=hd.quote(user.full_name))) - + # Auto-apply promo code if provided via start parameter if promo_code_to_apply: try: from bot.services.promo_code_service import PromoCodeService promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n) - + success, result = await promo_code_service.apply_promo_code( session, user_id, promo_code_to_apply, current_lang ) - + if success: await session.commit() logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}") - + # Get updated subscription details active = await subscription_service.get_active_subscription_details(session, user_id) config_link = active.get("config_link") if active else None config_link = config_link or _("config_link_not_available") - + new_end_date = result if isinstance(result, datetime) else None - + promo_success_text = _( "promo_code_applied_success_full", end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"), config_link=config_link, ) - + from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard await message.answer( promo_success_text, reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link), parse_mode="HTML" ) - + # Don't show main menu if promo was successfully applied return else: await session.rollback() logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}") # Continue to show main menu if promo failed - + except Exception as e: logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}") await session.rollback() - + await send_main_menu(message, settings, i18n_data, @@ -404,10 +404,13 @@ async def main_action_callback_handler( await user_subscription_handlers.display_subscription_options( callback, i18n_data, settings, session) elif action == "my_subscription": - await user_subscription_handlers.my_subscription_command_handler( callback, i18n_data, settings, panel_service, subscription_service, session, bot) + elif action == "my_devices": + await user_subscription_handlers.my_devices_command_handler( + callback, i18n_data, settings, panel_service, subscription_service, + session, bot) elif action == "referral": await user_referral_handlers.referral_command_handler( callback, settings, i18n_data, referral_service, bot, session) diff --git a/bot/handlers/user/subscription/__init__.py b/bot/handlers/user/subscription/__init__.py index a4582aa..89a6c2c 100644 --- a/bot/handlers/user/subscription/__init__.py +++ b/bot/handlers/user/subscription/__init__.py @@ -12,6 +12,6 @@ router.include_router(payments.router) router.include_router(payment_methods.router) # Re-export commonly used entrypoints for backward compatibility -from .core import display_subscription_options, my_subscription_command_handler # noqa: E402,F401 +from .core import display_subscription_options, my_subscription_command_handler, my_devices_command_handler # noqa: E402,F401 diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 5fbd66e..3299149 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -173,6 +173,13 @@ async def my_subscription_command_handler( ) ]) + prepend_rows.append([ + InlineKeyboardButton( + text=get_text("devices_button"), + 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): toggle_text = ( @@ -216,6 +223,120 @@ async def my_subscription_command_handler( await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) +@router.callback_query(F.data == "main_action:my_devices") +async def my_devices_command_handler( + event: Union[types.Message, types.CallbackQuery], + i18n_data: dict, + settings: Settings, + panel_service: PanelApiService, + subscription_service: SubscriptionService, + session: AsyncSession, + bot: Bot, +): + target = event.message if isinstance(event, types.CallbackQuery) else event + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: JsonI18n = i18n_data.get("i18n_instance") + get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw) + + if not i18n or not target: + if isinstance(event, types.Message): + await event.answer(get_text("error_occurred_try_again")) + return + + # TODO: context? + active = await subscription_service.get_active_subscription_details(session, event.from_user.id) + + 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")) + return + + max_devices = active.get("max_devices") + + 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) + else: + devices_list = [] + current_devices = len(devices.get('devices') or []) + for index, device in enumerate(devices.get('devices') or [], start=1): + device_model = device.get('deviceModel') or None + platform = device.get('platform') or None + user_agent = device.get('userAgent') or None + os_version = device.get('osVersion') or None + created_at = device.get('createdAt') + hwid = device.get('hwid') + created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M") + + 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) + + base_markup = get_back_to_main_menu_markup(current_lang, i18n, callback_data="main_action:my_subscription") + kb = base_markup.inline_keyboard + + devices_kb = [] + for index, device in enumerate(devices.get('devices') or [], start=1): + hwid = device.get('hwid') + device_button_text = get_text("disconnect_device_button", hwid=hwid, index=index) + + devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid}")]) + kb = devices_kb + kb + markup = InlineKeyboardMarkup(inline_keyboard=kb) + + if isinstance(event, types.CallbackQuery): + try: + await event.answer() + except Exception: + pass + try: + await event.message.edit_text(text, reply_markup=markup) + except Exception: + await event.message.answer(text, reply_markup=markup) + else: + await target.answer(text, reply_markup=markup) + + +@router.callback_query(F.data.startswith("disconnect_device:")) +async def disconnect_device_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + subscription_service: SubscriptionService, + panel_service: PanelApiService, + bot: Bot, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + try: + _, hwid = callback.data.split(":", 1) + except Exception: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + active = await subscription_service.get_active_subscription_details(session, callback.from_user.id) + if not active: + await callback.answer(get_text("subscription_not_active"), show_alert=True) + return + + success = await panel_service.disconnect_device(active.get("user_id"), hwid) + if not success: + await callback.answer(get_text("error_try_again"), show_alert=True) + return + await session.commit() + try: + await callback.answer(get_text("device_disconnected")) + except Exception: + pass + await my_devices_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) + + @router.callback_query(F.data.startswith("toggle_autorenew:")) async def toggle_autorenew_handler( callback: types.CallbackQuery, diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 456eae4..b8cdfc7 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -159,11 +159,16 @@ def get_referral_link_keyboard(lang: str, def get_back_to_main_menu_markup(lang: str, - i18n_instance) -> InlineKeyboardMarkup: + i18n_instance, + callback_data: Optional[str] = None) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() - builder.button(text=_(key="back_to_main_menu_button"), - callback_data="main_action:back_to_main") + if callback_data: + builder.button(text=_(key="back_to_main_menu_button"), + callback_data=callback_data) + else: + builder.button(text=_(key="back_to_main_menu_button"), + callback_data="main_action:back_to_main") return builder.as_markup() diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index 2f036b1..b26a84c 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -21,11 +21,11 @@ class PanelApiService: self.api_key = settings.PANEL_API_KEY self._session: Optional[aiohttp.ClientSession] = None self.default_client_ip = "127.0.0.1" - + async def __aenter__(self): """Context manager entry""" return self - + async def __aexit__(self, exc_type, exc_val, exc_tb): """Context manager exit - automatically close session""" await self.close_session() @@ -455,6 +455,30 @@ class PanelApiService: return f"{base_sub_url}/{client_type.lower()}" return base_sub_url + async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]: + endpoint = f"/hwid/devices/{user_uuid}" + response_data = await self._request("GET", endpoint, log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + return response_data.get("response") + logging.error( + f"Failed to get user devices for user {user_uuid}. Response: {response_data}" + ) + return None + + async def disconnect_device(self, user_uuid: str, hwid: str) -> bool: + endpoint = f"/hwid/devices/delete" + payload = { + "userUuid": user_uuid, + "hwid": hwid + } + response_data = await self._request("POST", endpoint, json=payload, log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + return True + logging.error( + f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}" + ) + return False + async def update_bot_db_sync_status(self, session: AsyncSession, status: str, @@ -468,25 +492,24 @@ class PanelApiService: async def get_bot_db_last_sync_status( self, session: AsyncSession) -> Optional[PanelSyncStatus]: return await panel_sync_dal.get_panel_sync_status(session) - - + async def get_system_stats(self) -> Optional[Dict[str, Any]]: """Get system statistics (CPU, memory, users counts)""" response_data = await self._request("GET", "/system/stats", log_full_response=False) if response_data and not response_data.get("error") and "response" in response_data: return response_data.get("response") return None - + async def get_bandwidth_stats(self) -> Optional[Dict[str, Any]]: """Get bandwidth statistics""" response_data = await self._request("GET", "/system/stats/bandwidth", log_full_response=False) if response_data and not response_data.get("error") and "response" in response_data: return response_data.get("response") return None - + async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]: """Get nodes statistics""" response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False) if response_data and not response_data.get("error") and "response" in response_data: return response_data.get("response") - return None \ No newline at end of file + return None diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index eb71475..1e784a1 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -593,10 +593,10 @@ class SubscriptionService: ) start_date = datetime.now(timezone.utc) new_end_date_obj = start_date + timedelta(days=bonus_days) - + # For promo code activations, use the configured user traffic limit traffic_limit = self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else self.settings.trial_traffic_limit_bytes - + bonus_sub_payload = { "user_id": user_id, "panel_user_uuid": panel_uuid, @@ -635,7 +635,7 @@ class SubscriptionService: ), include_uuid=False, ) - + panel_update_success = ( await self.panel_service.update_user_details_on_panel( panel_uuid, @@ -741,6 +741,7 @@ class SubscriptionService: ) return { + "user_id": panel_user_data.get("uuid"), "end_date": panel_end_date, "status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(), "config_link": panel_user_data.get("subscriptionUrl"), @@ -748,6 +749,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"), } async def get_subscriptions_ending_soon( diff --git a/locales/en.json b/locales/en.json index 6d6b0a3..d40e7ff 100644 --- a/locales/en.json +++ b/locales/en.json @@ -35,6 +35,15 @@ "pay_with_stars_button": "🌟 Telegram Stars", "connect_button": "🔗 Connect", "cancel_button": "❌ Cancel", + + "devices_button": "📱 My Devices ({current_devices}/{max_devices})", + "my_devices_details": "📱 My Devices ({current_devices}/{max_devices})\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n
Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.
", + "no_devices_details_found_message": "📱 My Devices\n\nYou don't have any devices yet.\nYou have {max_devices} devices available. You can connect them by clicking the \"🔗 Connect\" button in the subscription menu.", + "device_details": "{index}) {device_model} ({platform} {os_version})\nDate connected: {created_at_str}\nUser Agent: {user_agent}\nHWID: {hwid}", + "disconnect_device_button": "🚫 Disconnect {hwid} ({index})", + "device_disconnected": "Device disconnected successfully.", + "no_devices_found": "Error loading device list.", + "payment_description_subscription": "Subscription payment for {months} mo.", "payment_link_message": "To pay for {months} mo. subscription, click the button below:", "payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.", diff --git a/locales/ru.json b/locales/ru.json index 686bc23..60cb41f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -34,6 +34,15 @@ "pay_with_tribute_button": "❤️ Tribute", "pay_with_stars_button": "🌟 Звезды Telegram", "connect_button": "🔗 Подключиться", + + "devices_button": "📱 Мои устройства", + "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": "Ошибка загрузки списка устройств.", + "cancel_button": "❌ Отмена", "payment_description_subscription": "Оплата подписки на {months} мес.", "payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",