import html as html_mod from datetime import UTC, datetime from aiogram import types from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.subscription import add_subscription_devices from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance from app.database.models import TransactionType, User from app.keyboards.inline import ( get_app_selection_keyboard, get_back_keyboard, get_change_devices_keyboard, get_confirm_change_devices_keyboard, get_connection_guide_keyboard, get_device_management_help_keyboard, get_devices_management_keyboard, get_insufficient_balance_keyboard, get_specific_app_keyboard, ) from app.localization.texts import get_texts from app.services.remnawave_service import RemnaWaveService from app.services.subscription_service import SubscriptionService from app.services.user_cart_service import user_cart_service from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( apply_percentage_discount, calculate_prorated_price, get_remaining_months, ) from app.utils.subscription_utils import ( get_display_subscription_link, ) from .common import ( _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_apps_for_platform_async, get_device_name, logger, render_guide_blocks, ) from .countries import _get_available_countries async def get_current_devices_detailed(db_user: User) -> dict: try: if not db_user.remnawave_uuid: return {'count': 0, 'devices': []} from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') if response and 'response' in response: devices_info = response['response'] total_devices = devices_info.get('total', 0) devices_list = devices_info.get('devices', []) return {'count': total_devices, 'devices': devices_list[:5]} return {'count': 0, 'devices': []} except Exception as e: logger.error('Ошибка получения детальной информации об устройствах', error=e) return {'count': 0, 'devices': []} async def get_servers_display_names(squad_uuids: list[str]) -> str: if not squad_uuids: return 'Нет серверов' try: from app.database.crud.server_squad import get_server_squad_by_uuid from app.database.database import AsyncSessionLocal server_names = [] async with AsyncSessionLocal() as db: for uuid in squad_uuids: server = await get_server_squad_by_uuid(db, uuid) if server: server_names.append(server.display_name) logger.debug('Найден сервер в БД', uuid=uuid, display_name=server.display_name) else: logger.warning('Сервер с UUID не найден в БД', uuid=uuid) if not server_names: countries = await _get_available_countries() for uuid in squad_uuids: for country in countries: if country['uuid'] == uuid: server_names.append(country['name']) logger.debug('Найден сервер в кэше', uuid=uuid, country=country['name']) break if not server_names: if len(squad_uuids) == 1: return '🎯 Тестовый сервер' return f'{len(squad_uuids)} стран' if len(server_names) > 6: displayed = ', '.join(server_names[:6]) remaining = len(server_names) - 6 return f'{displayed} и ещё {remaining}' return ', '.join(server_names) except Exception as e: logger.error('Ошибка получения названий серверов', error=e) if len(squad_uuids) == 1: return '🎯 Тестовый сервер' return f'{len(squad_uuids)} стран' async def get_current_devices_count(db_user: User) -> str: try: if not db_user.remnawave_uuid: return '—' from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') if response and 'response' in response: total_devices = response['response'].get('total', 0) return str(total_devices) return '—' except Exception as e: logger.error('Ошибка получения количества устройств', error=e) return '—' async def handle_change_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription or subscription.is_trial: await callback.answer( texts.t('PAID_FEATURE_ONLY', '⚠️ Эта функция доступна только для платных подписок'), show_alert=True, ) return # Проверяем тариф подписки tariff = None if subscription.tariff_id: from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) # Для тарифов - проверяем разрешено ли изменение устройств tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None if tariff: if tariff_device_price is None or tariff_device_price <= 0: await callback.answer( texts.t('TARIFF_DEVICES_DISABLED', '⚠️ Изменение устройств недоступно для вашего тарифа'), show_alert=True, ) return # Для обычных подписок проверяем глобальную настройку elif not settings.is_devices_selection_enabled(): await callback.answer( texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'), show_alert=True, ) return current_devices = subscription.device_limit period_hint_days = _get_period_hint_from_subscription(subscription) devices_discount_percent = _get_addon_discount_percent_for_user( db_user, 'devices', period_hint_days, ) # Для тарифов показываем цену из тарифа if tariff: price_per_device = tariff_device_price price_text = texts.format_price(price_per_device) tariff_min_devices = getattr(tariff, 'device_limit', 1) or 1 # Добавляем информацию о минимальном лимите если он больше 1 min_devices_info = '' if tariff_min_devices > 1: min_devices_info = texts.t( 'CHANGE_DEVICES_MIN_LIMIT_INFO', '\nМинимум для тарифа: {min_devices} устройств\n', ).format(min_devices=tariff_min_devices) prompt_text = texts.t( 'CHANGE_DEVICES_PROMPT_TARIFF', ( '📱 Изменение количества устройств\n\n' 'Текущий лимит: {current_devices} устройств\n' 'Цена за доп. устройство: {price}/мес\n' '{min_devices_info}' 'Выберите новое количество устройств:\n\n' '💡 Важно:\n' '• При увеличении - доплата пропорционально оставшемуся времени\n' '• При уменьшении - возврат средств не производится' ), ).format(current_devices=current_devices, price=price_text, min_devices_info=min_devices_info) else: prompt_text = texts.t( 'CHANGE_DEVICES_PROMPT', ( '📱 Изменение количества устройств\n\n' 'Текущий лимит: {current_devices} устройств\n' 'Выберите новое количество устройств:\n\n' '💡 Важно:\n' '• При увеличении - доплата пропорционально оставшемуся времени\n' '• При уменьшении - возврат средств не производится' ), ).format(current_devices=current_devices) await callback.message.edit_text( prompt_text, reply_markup=get_change_devices_keyboard( current_devices, db_user.language, subscription.end_date, devices_discount_percent, tariff=tariff, ), parse_mode='HTML', ) await callback.answer() async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription # Проверяем тариф подписки tariff = None if subscription.tariff_id: from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) # Для тарифов - проверяем разрешено ли изменение устройств tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None if tariff: if tariff_device_price is None or tariff_device_price <= 0: await callback.answer( texts.t('TARIFF_DEVICES_DISABLED', '⚠️ Изменение устройств недоступно для вашего тарифа'), show_alert=True, ) return price_per_device = tariff_device_price else: if not settings.is_devices_selection_enabled(): await callback.answer( texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'), show_alert=True, ) return price_per_device = settings.PRICE_PER_DEVICE current_devices = subscription.device_limit if new_devices_count == current_devices: await callback.answer( texts.t('DEVICES_NO_CHANGE', 'ℹ️ Количество устройств не изменилось'), show_alert=True, ) return if settings.MAX_DEVICES_LIMIT > 0 and new_devices_count > settings.MAX_DEVICES_LIMIT: await callback.answer( texts.t( 'DEVICES_LIMIT_EXCEEDED', '⚠️ Превышен максимальный лимит устройств ({limit})', ).format(limit=settings.MAX_DEVICES_LIMIT), show_alert=True, ) return # Проверяем минимальное количество устройств на тарифе tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1 if new_devices_count < tariff_min_devices: await callback.answer( texts.t( 'DEVICES_MIN_LIMIT_REACHED', '⚠️ Минимальное количество устройств для вашего тарифа: {limit}', ).format(limit=tariff_min_devices), show_alert=True, ) return devices_difference = new_devices_count - current_devices if devices_difference > 0: additional_devices = devices_difference # Для тарифов - все устройства платные (нет бесплатного лимита) if tariff: chargeable_devices = additional_devices elif current_devices < settings.DEFAULT_DEVICE_LIMIT: free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices chargeable_devices = max(0, additional_devices - free_devices) else: chargeable_devices = additional_devices devices_price_per_month = chargeable_devices * price_per_device # Проверяем является ли тариф суточным is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) if is_daily_tariff: # Для суточных тарифов считаем по дням (как в кабинете) now = datetime.now(UTC) days_left = max(1, (subscription.end_date - now).days) period_hint_days = days_left devices_discount_percent = _get_addon_discount_percent_for_user( db_user, 'devices', period_hint_days, ) discounted_per_month, discount_per_month = apply_percentage_discount( devices_price_per_month, devices_discount_percent, ) # Цена = месячная_цена * days_left / 30 price = int(discounted_per_month * days_left / 30) price = max(100, price) # Минимум 1 рубль total_discount = int(discount_per_month * days_left / 30) period_label = f'{days_left} дн.' if days_left > 1 else '1 день' else: # Для обычных тарифов - по месяцам months_hint = get_remaining_months(subscription.end_date) period_hint_days = months_hint * 30 if months_hint > 0 else None devices_discount_percent = _get_addon_discount_percent_for_user( db_user, 'devices', period_hint_days, ) discounted_per_month, discount_per_month = apply_percentage_discount( devices_price_per_month, devices_discount_percent, ) price, charged_months = calculate_prorated_price( discounted_per_month, subscription.end_date, ) total_discount = discount_per_month * charged_months period_label = f'{charged_months} мес' if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f'{texts.format_price(price)} (за {period_label})' message_text = texts.t( 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( '⚠️ Недостаточно средств\n\n' 'Стоимость услуги: {required}\n' 'На балансе: {balance}\n' 'Не хватает: {missing}\n\n' 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( required=required_text, balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) # Сохраняем корзину для автопокупки после пополнения баланса await user_cart_service.save_user_cart( user_id=db_user.id, cart_data={ 'cart_mode': 'add_devices', 'devices_to_add': devices_difference, 'price_kopeks': price, }, ) logger.info( 'Сохранена корзина add_devices для пользователя : + устройств, цена коп.', telegram_id=db_user.telegram_id, devices_difference=devices_difference, price=price, ) await callback.message.answer( message_text, reply_markup=get_insufficient_balance_keyboard( db_user.language, amount_kopeks=missing_kopeks, has_saved_cart=True, ), parse_mode='HTML', ) await callback.answer() return action_text = texts.t( 'DEVICE_CHANGE_ACTION_INCREASE', 'увеличить до {count}', ).format(count=new_devices_count) if price > 0: cost_text = texts.t( 'DEVICE_CHANGE_EXTRA_COST', 'Доплата: {amount} (за {period})', ).format( amount=texts.format_price(price), period=period_label, months=period_label, ) if total_discount > 0: cost_text += texts.t( 'DEVICE_CHANGE_DISCOUNT_INFO', ' (скидка {percent}%: -{amount})', ).format( percent=devices_discount_percent, amount=texts.format_price(total_discount), ) else: cost_text = texts.t('DEVICE_CHANGE_FREE', 'Бесплатно') else: price = 0 action_text = texts.t( 'DEVICE_CHANGE_ACTION_DECREASE', 'уменьшить до {count}', ).format(count=new_devices_count) cost_text = texts.t('DEVICE_CHANGE_NO_REFUND', 'Возврат средств не производится') # Проверяем количество подключённых устройств для предупреждения devices_warning = '' if new_devices_count < current_devices and db_user.remnawave_uuid: try: service = RemnaWaveService() async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') if response and 'response' in response: connected_count = response['response'].get('total', 0) if connected_count > new_devices_count: devices_warning = texts.t( 'DEVICE_CHANGE_RESET_WARNING', ( '\n⚠️ Внимание!\n' 'У вас подключено {connected} устройств.\n' 'При уменьшении лимита до {new} все устройства будут сброшены.\n' 'Вам нужно будет заново подключить нужные устройства.\n' ), ).format(connected=connected_count, new=new_devices_count) except Exception as e: logger.error('Ошибка проверки устройств', error=e) confirm_text = texts.t( 'DEVICE_CHANGE_CONFIRMATION', ( '📱 Подтверждение изменения\n\n' 'Текущее количество: {current} устройств\n' 'Новое количество: {new} устройств\n\n' 'Действие: {action}\n' '💰 {cost}\n\n' 'Подтвердить изменение?' ), ).format( current=current_devices, new=new_devices_count, action=action_text, cost=cost_text, ) if devices_warning: confirm_text += devices_warning await callback.message.edit_text( confirm_text, reply_markup=get_confirm_change_devices_keyboard(new_devices_count, price, db_user.language), parse_mode='HTML', ) await callback.answer() async def execute_change_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): callback_parts = callback.data.split('_') new_devices_count = int(callback_parts[3]) price = int(callback_parts[4]) texts = get_texts(db_user.language) subscription = db_user.subscription current_devices = subscription.device_limit # Проверяем тариф подписки tariff = None if subscription.tariff_id: from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) # Для тарифов - проверяем разрешено ли изменение устройств if tariff: tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff_device_price is None or tariff_device_price <= 0: await callback.answer( texts.t('TARIFF_DEVICES_DISABLED', '⚠️ Изменение устройств недоступно для вашего тарифа'), show_alert=True, ) return elif not settings.is_devices_selection_enabled(): await callback.answer( texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'), show_alert=True, ) return # Проверяем минимальное количество устройств на тарифе tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1 if new_devices_count < tariff_min_devices: await callback.answer( texts.t( 'DEVICES_MIN_LIMIT_REACHED', '⚠️ Минимальное количество устройств для вашего тарифа: {limit}', ).format(limit=tariff_min_devices), show_alert=True, ) return try: if price > 0: success = await subtract_user_balance( db, db_user, price, f'Изменение количества устройств с {current_devices} до {new_devices_count}' ) if not success: await callback.answer( texts.t('PAYMENT_CHARGE_ERROR', '⚠️ Ошибка списания средств'), show_alert=True, ) return charged_months = get_remaining_months(subscription.end_date) await create_transaction( db=db, user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, description=f'Изменение устройств с {current_devices} до {new_devices_count} на {charged_months} мес', ) subscription.device_limit = new_devices_count subscription.updated_at = datetime.now(UTC) await db.commit() subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) # При уменьшении лимита - удалить лишние устройства (последние подключённые) devices_reset_count = 0 if new_devices_count < current_devices and db_user.remnawave_uuid: try: service = RemnaWaveService() async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') if response and 'response' in response: devices_list = response['response'].get('devices', []) connected_count = len(devices_list) # Если подключённых устройств больше чем новый лимит - удалить лишние if connected_count > new_devices_count: devices_to_remove = connected_count - new_devices_count logger.info( '🔧 Удаление лишних устройств при уменьшении лимита: подключено новый лимит удаляем', connected_count=connected_count, new_devices_count=new_devices_count, devices_to_remove=devices_to_remove, ) # Сортируем по дате (последние в конце) и удаляем последние sorted_devices = sorted( devices_list, key=lambda d: d.get('updatedAt') or d.get('createdAt') or '', ) devices_to_delete = sorted_devices[-devices_to_remove:] for device in devices_to_delete: device_hwid = device.get('hwid') if device_hwid: try: delete_data = {'userUuid': db_user.remnawave_uuid, 'hwid': device_hwid} await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) devices_reset_count += 1 logger.info('✅ Удалено устройство', device_hwid=device_hwid) except Exception as del_error: logger.error( 'Ошибка удаления устройства', device_hwid=device_hwid, del_error=del_error ) except Exception as reset_error: logger.error('Ошибка удаления устройств при уменьшении лимита', reset_error=reset_error) await db.refresh(db_user) await db.refresh(subscription) try: from app.services.admin_notification_service import AdminNotificationService notification_service = AdminNotificationService(callback.bot) await notification_service.send_subscription_update_notification( db, db_user, subscription, 'devices', current_devices, new_devices_count, price ) except Exception as e: logger.error('Ошибка отправки уведомления об изменении устройств', error=e) if new_devices_count > current_devices: success_text = texts.t( 'DEVICE_CHANGE_INCREASE_SUCCESS', '✅ Количество устройств увеличено!\n\n', ) success_text += texts.t( 'DEVICE_CHANGE_RESULT_LINE', '📱 Было: {old} → Стало: {new}\n', ).format(old=current_devices, new=new_devices_count) if price > 0: success_text += texts.t( 'DEVICE_CHANGE_CHARGED', '💰 Списано: {amount}', ).format(amount=texts.format_price(price)) else: success_text = texts.t( 'DEVICE_CHANGE_DECREASE_SUCCESS', '✅ Количество устройств уменьшено!\n\n', ) success_text += texts.t( 'DEVICE_CHANGE_RESULT_LINE', '📱 Было: {old} → Стало: {new}\n', ).format(old=current_devices, new=new_devices_count) if devices_reset_count > 0: success_text += texts.t( 'DEVICE_CHANGE_DEVICES_REMOVED', '\n🗑 Удалено устройств: {count}\n', ).format(count=devices_reset_count) success_text += texts.t( 'DEVICE_CHANGE_NO_REFUND_INFO', 'ℹ️ Возврат средств не производится', ) await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language)) logger.info( '✅ Пользователь изменил количество устройств с на доплата: ₽', telegram_id=db_user.telegram_id, current_devices=current_devices, new_devices_count=new_devices_count, price=price / 100, ) except Exception as e: logger.error('Ошибка изменения количества устройств', error=e) await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() async def handle_device_management(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription or subscription.is_trial: await callback.answer( texts.t('PAID_FEATURE_ONLY', '⚠️ Эта функция доступна только для платных подписок'), show_alert=True, ) return if not db_user.remnawave_uuid: await callback.answer( texts.t('DEVICE_UUID_NOT_FOUND', '❌ UUID пользователя не найден'), show_alert=True, ) return try: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') if response and 'response' in response: devices_info = response['response'] total_devices = devices_info.get('total', 0) devices_list = devices_info.get('devices', []) if total_devices == 0: await callback.message.edit_text( texts.t('DEVICE_NONE_CONNECTED', 'ℹ️ У вас нет подключенных устройств'), reply_markup=get_back_keyboard(db_user.language), ) await callback.answer() return await show_devices_page(callback, db_user, devices_list, page=1) else: await callback.answer( texts.t( 'DEVICE_FETCH_INFO_ERROR', '❌ Ошибка получения информации об устройствах', ), show_alert=True, ) except Exception as e: logger.error('Ошибка получения списка устройств', error=e) await callback.answer( texts.t( 'DEVICE_FETCH_INFO_ERROR', '❌ Ошибка получения информации об устройствах', ), show_alert=True, ) await callback.answer() async def show_devices_page(callback: types.CallbackQuery, db_user: User, devices_list: list[dict], page: int = 1): texts = get_texts(db_user.language) devices_per_page = 5 pagination = paginate_list(devices_list, page=page, per_page=devices_per_page) devices_text = texts.t( 'DEVICE_MANAGEMENT_OVERVIEW', ( '🔄 Управление устройствами\n\n' '📊 Всего подключено: {total} устройств\n' '📄 Страница {page} из {pages}\n\n' ), ).format(total=len(devices_list), page=pagination.page, pages=pagination.total_pages) if pagination.items: devices_text += texts.t( 'DEVICE_MANAGEMENT_CONNECTED_HEADER', 'Подключенные устройства:\n', ) for i, device in enumerate(pagination.items, 1): platform = device.get('platform', 'Unknown') device_model = device.get('deviceModel', 'Unknown') device_info = f'{platform} - {device_model}' if len(device_info) > 35: device_info = device_info[:32] + '...' devices_text += texts.t( 'DEVICE_MANAGEMENT_LIST_ITEM', '• {device}\n', ).format(device=device_info) devices_text += texts.t( 'DEVICE_MANAGEMENT_ACTIONS', ('\n💡 Действия:\n• Выберите устройство для сброса\n• Или сбросьте все устройства сразу'), ) await callback.message.edit_text( devices_text, reply_markup=get_devices_management_keyboard(pagination.items, pagination, db_user.language), parse_mode='HTML', ) async def handle_devices_page(callback: types.CallbackQuery, db_user: User, db: AsyncSession): page = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) try: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') if response and 'response' in response: devices_list = response['response'].get('devices', []) await show_devices_page(callback, db_user, devices_list, page=page) else: await callback.answer( texts.t('DEVICE_FETCH_ERROR', '❌ Ошибка получения устройств'), show_alert=True, ) except Exception as e: logger.error('Ошибка перехода на страницу устройств', error=e) await callback.answer( texts.t('DEVICE_PAGE_LOAD_ERROR', '❌ Ошибка загрузки страницы'), show_alert=True, ) async def handle_single_device_reset(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) try: callback_parts = callback.data.split('_') if len(callback_parts) < 4: logger.error('Некорректный формат callback_data', callback_data=callback.data) await callback.answer( texts.t('DEVICE_RESET_INVALID_REQUEST', '❌ Ошибка: некорректный запрос'), show_alert=True, ) return device_index = int(callback_parts[2]) page = int(callback_parts[3]) logger.info('🔧 Сброс устройства: index=, page', device_index=device_index, page=page) except (ValueError, IndexError) as e: logger.error('❌ Ошибка парсинга callback_data', callback_data=callback.data, error=e) await callback.answer( texts.t('DEVICE_RESET_PARSE_ERROR', '❌ Ошибка обработки запроса'), show_alert=True, ) return try: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') if response and 'response' in response: devices_list = response['response'].get('devices', []) devices_per_page = 5 pagination = paginate_list(devices_list, page=page, per_page=devices_per_page) if device_index < len(pagination.items): device = pagination.items[device_index] device_hwid = device.get('hwid') if device_hwid: delete_data = {'userUuid': db_user.remnawave_uuid, 'hwid': device_hwid} await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) platform = device.get('platform', 'Unknown') device_model = device.get('deviceModel', 'Unknown') device_info = f'{platform} - {device_model}' await callback.answer( texts.t( 'DEVICE_RESET_SUCCESS', '✅ Устройство {device} успешно сброшено!', ).format(device=device_info), show_alert=True, ) updated_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') if updated_response and 'response' in updated_response: updated_devices = updated_response['response'].get('devices', []) if updated_devices: updated_pagination = paginate_list( updated_devices, page=page, per_page=devices_per_page ) if not updated_pagination.items and page > 1: page = page - 1 await show_devices_page(callback, db_user, updated_devices, page=page) else: await callback.message.edit_text( texts.t( 'DEVICE_RESET_ALL_DONE', 'ℹ️ Все устройства сброшены', ), reply_markup=get_back_keyboard(db_user.language), ) logger.info( '✅ Пользователь сбросил устройство', telegram_id=db_user.telegram_id, device_info=device_info, ) else: await callback.answer( texts.t( 'DEVICE_RESET_ID_FAILED', '❌ Не удалось получить ID устройства', ), show_alert=True, ) else: await callback.answer( texts.t('DEVICE_RESET_NOT_FOUND', '❌ Устройство не найдено'), show_alert=True, ) else: await callback.answer( texts.t('DEVICE_FETCH_ERROR', '❌ Ошибка получения устройств'), show_alert=True, ) except Exception as e: logger.error('Ошибка сброса устройства', error=e) await callback.answer( texts.t('DEVICE_RESET_ERROR', '❌ Ошибка сброса устройства'), show_alert=True, ) async def handle_all_devices_reset_from_management(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) if not db_user.remnawave_uuid: await callback.answer( texts.t('DEVICE_UUID_NOT_FOUND', '❌ UUID пользователя не найден'), show_alert=True, ) return try: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() async with service.get_api_client() as api: devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') if not devices_response or 'response' not in devices_response: await callback.answer( texts.t( 'DEVICE_LIST_FETCH_ERROR', '❌ Ошибка получения списка устройств', ), show_alert=True, ) return devices_list = devices_response['response'].get('devices', []) if not devices_list: await callback.answer( texts.t('DEVICE_NONE_CONNECTED', 'ℹ️ У вас нет подключенных устройств'), show_alert=True, ) return logger.info('🔧 Найдено устройств для сброса', devices_list_count=len(devices_list)) success_count = 0 failed_count = 0 for device in devices_list: device_hwid = device.get('hwid') if device_hwid: try: delete_data = {'userUuid': db_user.remnawave_uuid, 'hwid': device_hwid} await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) success_count += 1 logger.info('✅ Устройство удалено', device_hwid=device_hwid) except Exception as device_error: failed_count += 1 logger.error( '❌ Ошибка удаления устройства', device_hwid=device_hwid, device_error=device_error ) else: failed_count += 1 logger.warning('⚠️ У устройства нет HWID', device=device) if success_count > 0: if failed_count == 0: await callback.message.edit_text( texts.t( 'DEVICE_RESET_ALL_SUCCESS_MESSAGE', ( '✅ Все устройства успешно сброшены!\n\n' '🔄 Сброшено: {count} устройств\n' '📱 Теперь вы можете заново подключить свои устройства\n\n' "💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения" ), ).format(count=success_count), reply_markup=get_back_keyboard(db_user.language), parse_mode='HTML', ) logger.info( '✅ Пользователь успешно сбросил устройств', telegram_id=db_user.telegram_id, success_count=success_count, ) else: await callback.message.edit_text( texts.t( 'DEVICE_RESET_PARTIAL_MESSAGE', ( '⚠️ Частичный сброс устройств\n\n' '✅ Удалено: {success} устройств\n' '❌ Не удалось удалить: {failed} устройств\n\n' 'Попробуйте еще раз или обратитесь в поддержку.' ), ).format(success=success_count, failed=failed_count), reply_markup=get_back_keyboard(db_user.language), parse_mode='HTML', ) logger.warning( '⚠️ Частичный сброс у пользователя /', telegram_id=db_user.telegram_id, success_count=success_count, devices_list_count=len(devices_list), ) else: await callback.message.edit_text( texts.t( 'DEVICE_RESET_ALL_FAILED_MESSAGE', ( '❌ Не удалось сбросить устройства\n\n' 'Попробуйте еще раз позже или обратитесь в техподдержку.\n\n' 'Всего устройств: {total}' ), ).format(total=len(devices_list)), reply_markup=get_back_keyboard(db_user.language), parse_mode='HTML', ) logger.error( '❌ Не удалось сбросить ни одного устройства у пользователя', telegram_id=db_user.telegram_id ) except Exception as e: logger.error('Ошибка сброса всех устройств', error=e) await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription # Проверяем тариф подписки tariff = None if subscription.tariff_id: from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) # Для тарифов - проверяем разрешено ли добавление устройств tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None if tariff: if tariff_device_price is None or tariff_device_price <= 0: await callback.answer( texts.t('TARIFF_DEVICES_DISABLED', '⚠️ Добавление устройств недоступно для вашего тарифа'), show_alert=True, ) return price_per_device = tariff_device_price else: if not settings.is_devices_selection_enabled(): await callback.answer( texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'), show_alert=True, ) return price_per_device = settings.PRICE_PER_DEVICE resume_callback = None new_total_devices = subscription.device_limit + devices_count if settings.MAX_DEVICES_LIMIT > 0 and new_total_devices > settings.MAX_DEVICES_LIMIT: await callback.answer( f'⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT}). ' f'У вас: {subscription.device_limit}, добавляете: {devices_count}', show_alert=True, ) return devices_price_per_month = devices_count * price_per_device # Проверяем является ли тариф суточным is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) if is_daily_tariff: # Для суточных тарифов считаем по дням (как в кабинете) now = datetime.now(UTC) days_left = max(1, (subscription.end_date - now).days) period_hint_days = days_left devices_discount_percent = _get_addon_discount_percent_for_user( db_user, 'devices', period_hint_days, ) discounted_per_month, discount_per_month = apply_percentage_discount( devices_price_per_month, devices_discount_percent, ) # Цена = месячная_цена * days_left / 30 price = int(discounted_per_month * days_left / 30) price = max(100, price) # Минимум 1 рубль total_discount = int(discount_per_month * days_left / 30) period_label = f'{days_left} дн.' if days_left > 1 else '1 день' else: # Для обычных тарифов - по месяцам months_hint = get_remaining_months(subscription.end_date) period_hint_days = months_hint * 30 if months_hint > 0 else None devices_discount_percent = _get_addon_discount_percent_for_user( db_user, 'devices', period_hint_days, ) discounted_per_month, discount_per_month = apply_percentage_discount( devices_price_per_month, devices_discount_percent, ) price, charged_months = calculate_prorated_price( discounted_per_month, subscription.end_date, ) total_discount = discount_per_month * charged_months period_label = f'{charged_months} мес' logger.info( 'Добавление устройств: ₽/мес × = ₽ (скидка ₽)', devices_count=devices_count, discounted_per_month=discounted_per_month / 100, period_label=period_label, price=price / 100, total_discount=total_discount / 100, ) if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f'{texts.format_price(price)} (за {period_label})' message_text = texts.t( 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( '⚠️ Недостаточно средств\n\n' 'Стоимость услуги: {required}\n' 'На балансе: {balance}\n' 'Не хватает: {missing}\n\n' 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( required=required_text, balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) # Сохраняем корзину для автопокупки после пополнения баланса await user_cart_service.save_user_cart( user_id=db_user.id, cart_data={ 'cart_mode': 'add_devices', 'devices_to_add': devices_count, 'price_kopeks': price, }, ) logger.info( 'Сохранена корзина add_devices для пользователя : + устройств, цена коп.', telegram_id=db_user.telegram_id, devices_count=devices_count, price=price, ) await callback.message.edit_text( message_text, reply_markup=get_insufficient_balance_keyboard( db_user.language, resume_callback=resume_callback, amount_kopeks=missing_kopeks, has_saved_cart=True, ), parse_mode='HTML', ) await callback.answer() return try: success = await subtract_user_balance( db, db_user, price, f'Добавление {devices_count} устройств на {period_label}' ) if not success: await callback.answer('⚠️ Ошибка списания средств', show_alert=True) return await add_subscription_devices(db, subscription, devices_count) subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) await create_transaction( db=db, user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, description=f'Добавление {devices_count} устройств на {period_label}', ) await db.refresh(db_user) await db.refresh(subscription) # Отправляем уведомление админам о докупке устройств try: from app.services.admin_notification_service import AdminNotificationService notification_service = AdminNotificationService(callback.bot) old_device_limit = subscription.device_limit - devices_count await notification_service.send_subscription_update_notification( db, db_user, subscription, 'devices', old_device_limit, subscription.device_limit, price ) except Exception as e: logger.error('Ошибка отправки уведомления о докупке устройств', error=e) success_text = ( '✅ Устройства успешно добавлены!\n\n' f'📱 Добавлено: {devices_count} устройств\n' f'Новый лимит: {subscription.device_limit} устройств\n' ) success_text += f'💰 Списано: {texts.format_price(price)} (за {period_label})' if total_discount > 0: success_text += f' (скидка {devices_discount_percent}%: -{texts.format_price(total_discount)})' await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language)) logger.info( '✅ Пользователь добавил устройств за ₽', telegram_id=db_user.telegram_id, devices_count=devices_count, price=price / 100, ) except Exception as e: logger.error('Ошибка добавления устройств', error=e) await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() async def handle_reset_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): await handle_device_management(callback, db_user, db) async def confirm_reset_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): await handle_device_management(callback, db_user, db) async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db: AsyncSession): device_type = callback.data.split('_')[2] texts = get_texts(db_user.language) subscription = db_user.subscription subscription_link = get_display_subscription_link(subscription) if not subscription_link: await callback.answer( texts.t('SUBSCRIPTION_LINK_UNAVAILABLE', '❌ Ссылка подписки недоступна'), show_alert=True, ) return apps = await get_apps_for_platform_async(device_type, db_user.language) hide_subscription_link = settings.should_hide_subscription_link() if not apps: await callback.answer( texts.t('SUBSCRIPTION_DEVICE_APPS_NOT_FOUND', '❌ Приложения для этого устройства не найдены'), show_alert=True, ) return featured_app = next((app for app in apps if app.get('isFeatured', False)), apps[0]) featured_app_id = featured_app.get('id') other_apps = [app for app in apps if isinstance(app, dict) and app.get('id') and app.get('id') != featured_app_id] other_app_names = ', '.join( html_mod.escape(str(app.get('name')).strip()) for app in other_apps if isinstance(app.get('name'), str) and app.get('name').strip() ) if hide_subscription_link: link_section = ( texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 Ссылка подписки:') + '\n' + texts.t( 'SUBSCRIPTION_LINK_HIDDEN_NOTICE', 'ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".', ) + '\n\n' ) else: link_section = ( texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 Ссылка подписки:') + f'\n{html_mod.escape(subscription_link)}\n\n' ) guide_text = ( texts.t( 'SUBSCRIPTION_DEVICE_GUIDE_TITLE', '📱 Настройка для {device_name}', ).format(device_name=html_mod.escape(get_device_name(device_type, db_user.language))) + '\n\n' + link_section + texts.t( 'SUBSCRIPTION_DEVICE_FEATURED_APP', '📋 Рекомендуемое приложение: {app_name}', ).format(app_name=html_mod.escape(featured_app.get('name', ''))) ) if other_app_names: guide_text += '\n\n' + texts.t( 'SUBSCRIPTION_DEVICE_OTHER_APPS', '📦 Другие приложения: {app_list}', ).format(app_list=other_app_names) guide_text += '\n' + texts.t( 'SUBSCRIPTION_DEVICE_OTHER_APPS_HINT', 'Нажмите кнопку "Другие приложения" ниже, чтобы выбрать приложение.', ) blocks_text = render_guide_blocks(featured_app.get('blocks', []), db_user.language) if blocks_text: guide_text += '\n\n' + blocks_text guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_HOW_TO_TITLE', '💡 Как подключить:') guide_text += '\n' + '\n'.join( [ texts.t( 'SUBSCRIPTION_DEVICE_HOW_TO_STEP1', '1. Установите приложение по ссылке выше', ), texts.t( 'SUBSCRIPTION_DEVICE_HOW_TO_STEP2', '2. Нажмите кнопку "Подключиться" ниже', ), texts.t( 'SUBSCRIPTION_DEVICE_HOW_TO_STEP3', '3. Откройте приложение и вставьте ссылку', ), texts.t( 'SUBSCRIPTION_DEVICE_HOW_TO_STEP4', '4. Подключитесь к серверу', ), ] ) await callback.message.edit_text( guide_text, reply_markup=get_connection_guide_keyboard( subscription_link, featured_app, device_type, db_user.language, has_other_apps=bool(other_apps), ), parse_mode='HTML', ) await callback.answer() async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db: AsyncSession): device_type = callback.data.split('_')[2] texts = get_texts(db_user.language) apps = await get_apps_for_platform_async(device_type, db_user.language) if not apps: await callback.answer( texts.t('SUBSCRIPTION_DEVICE_APPS_NOT_FOUND', '❌ Приложения для этого устройства не найдены'), show_alert=True, ) return app_text = ( texts.t( 'SUBSCRIPTION_APPS_TITLE', '📱 Приложения для {device_name}', ).format(device_name=html_mod.escape(get_device_name(device_type, db_user.language))) + '\n\n' + texts.t('SUBSCRIPTION_APPS_PROMPT', 'Выберите приложение для подключения:') ) await callback.message.edit_text( app_text, reply_markup=get_app_selection_keyboard(device_type, apps, db_user.language), parse_mode='HTML' ) await callback.answer() async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User, db: AsyncSession): parts = callback.data.split('_', 2) if len(parts) < 3: await callback.answer('Invalid callback data', show_alert=True) return _, device_type, app_id = parts texts = get_texts(db_user.language) subscription = db_user.subscription subscription_link = get_display_subscription_link(subscription) if not subscription_link: await callback.answer( texts.t('SUBSCRIPTION_LINK_UNAVAILABLE', '❌ Ссылка подписки недоступна'), show_alert=True, ) return apps = await get_apps_for_platform_async(device_type, db_user.language) app = next((a for a in apps if a.get('id') == app_id), None) if apps else None if not app: await callback.answer( texts.t('SUBSCRIPTION_APP_NOT_FOUND', '❌ Приложение не найдено'), show_alert=True, ) return hide_subscription_link = settings.should_hide_subscription_link() if hide_subscription_link: link_section = ( texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 Ссылка подписки:') + '\n' + texts.t( 'SUBSCRIPTION_LINK_HIDDEN_NOTICE', 'ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".', ) + '\n\n' ) else: link_section = ( texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 Ссылка подписки:') + f'\n{html_mod.escape(subscription_link)}\n\n' ) guide_text = ( texts.t( 'SUBSCRIPTION_SPECIFIC_APP_TITLE', '📱 {app_name} - {device_name}', ).format( app_name=html_mod.escape(app.get('name', '')), device_name=html_mod.escape(get_device_name(device_type, db_user.language)), ) + '\n\n' + link_section ) blocks_text = render_guide_blocks(app.get('blocks', []), db_user.language) if blocks_text: guide_text += blocks_text + '\n\n' await callback.message.edit_text( guide_text, reply_markup=get_specific_app_keyboard( subscription_link, app, device_type, db_user.language, ), parse_mode='HTML', ) await callback.answer() async def show_device_connection_help(callback: types.CallbackQuery, db_user: User, db: AsyncSession): subscription = db_user.subscription subscription_link = get_display_subscription_link(subscription) if not subscription_link: await callback.answer('❌ Ссылка подписки недоступна', show_alert=True) return help_text = f""" 📱 Как подключить устройство заново После сброса устройства вам нужно: 1. Получить ссылку подписки: 📋 Скопируйте ссылку ниже или найдите её в разделе "Моя подписка" 2. Настроить VPN приложение: • Откройте ваше VPN приложение • Найдите функцию "Добавить подписку" или "Import" • Вставьте скопированную ссылку 3. Подключиться: • Выберите сервер • Нажмите "Подключить" 🔗 Ваша ссылка подписки: {html_mod.escape(subscription_link)} 💡 Совет: Сохраните эту ссылку - она понадобится для подключения новых устройств """ await callback.message.edit_text( help_text, reply_markup=get_device_management_help_keyboard(db_user.language), parse_mode='HTML' ) await callback.answer()