diff --git a/app/config.py b/app/config.py index ad178ed4..f75e7f49 100644 --- a/app/config.py +++ b/app/config.py @@ -20,6 +20,8 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [ "joingroup", ] +USER_TAG_PATTERN = re.compile(r"^[A-Z0-9_]{1,16}$") + logger = logging.getLogger(__name__) @@ -89,6 +91,7 @@ class Settings(BaseSettings): TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False TRIAL_PAYMENT_ENABLED: bool = False TRIAL_ACTIVATION_PRICE: int = 0 + TRIAL_USER_TAG: Optional[str] = None DEFAULT_TRAFFIC_LIMIT_GB: int = 100 DEFAULT_DEVICE_LIMIT: int = 1 DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH" @@ -120,6 +123,7 @@ class Settings(BaseSettings): PRICE_90_DAYS: int = 269000 PRICE_180_DAYS: int = 499000 PRICE_360_DAYS: int = 899000 + PAID_SUBSCRIPTION_USER_TAG: Optional[str] = None PRICE_TRAFFIC_5GB: int = 2000 PRICE_TRAFFIC_10GB: int = 3500 @@ -777,13 +781,48 @@ class Settings(BaseSettings): def kopeks_to_rubles(self, kopeks: int) -> float: return kopeks / 100 - + def rubles_to_kopeks(self, rubles: float) -> int: return int(rubles * 100) - + + @staticmethod + def _normalize_user_tag(value: Optional[str], setting_name: str) -> Optional[str]: + if value is None: + return None + + cleaned = str(value).strip().upper() + if not cleaned: + return None + + if len(cleaned) > 16: + logger.warning( + "Некорректная длина %s: максимум 16 символов, получено %s", + setting_name, + len(cleaned), + ) + return None + + if not USER_TAG_PATTERN.fullmatch(cleaned): + logger.warning( + "Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание", + setting_name, + ) + return None + + return cleaned + def get_trial_warning_hours(self) -> int: return self.TRIAL_WARNING_HOURS + def get_trial_user_tag(self) -> Optional[str]: + return self._normalize_user_tag(self.TRIAL_USER_TAG, "TRIAL_USER_TAG") + + def get_paid_subscription_user_tag(self) -> Optional[str]: + return self._normalize_user_tag( + self.PAID_SUBSCRIPTION_USER_TAG, + "PAID_SUBSCRIPTION_USER_TAG", + ) + def get_bot_username(self) -> Optional[str]: username = getattr(self, "BOT_USERNAME", None) if not username: diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index abae5463..2fccbeb4 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -27,14 +27,22 @@ class TrafficLimitStrategy(Enum): MONTH = "MONTH" +@dataclass +class UserTraffic: + """Данные о трафике пользователя (новая структура API)""" + used_traffic_bytes: int + lifetime_used_traffic_bytes: int + online_at: Optional[datetime] = None + first_connected_at: Optional[datetime] = None + last_connected_node_uuid: Optional[str] = None + + @dataclass class RemnaWaveUser: uuid: str short_uuid: str username: str status: UserStatus - used_traffic_bytes: int - lifetime_used_traffic_bytes: int traffic_limit_bytes: int traffic_limit_strategy: TrafficLimitStrategy expire_at: datetime @@ -47,18 +55,60 @@ class RemnaWaveUser: active_internal_squads: List[Dict[str, str]] created_at: datetime updated_at: datetime + user_traffic: Optional[UserTraffic] = None sub_last_user_agent: Optional[str] = None sub_last_opened_at: Optional[datetime] = None - online_at: Optional[datetime] = None sub_revoked_at: Optional[datetime] = None last_traffic_reset_at: Optional[datetime] = None trojan_password: Optional[str] = None vless_uuid: Optional[str] = None ss_password: Optional[str] = None - first_connected_at: Optional[datetime] = None last_triggered_threshold: int = 0 happ_link: Optional[str] = None happ_crypto_link: Optional[str] = None + external_squad_uuid: Optional[str] = None + id: Optional[int] = None + + @property + def used_traffic_bytes(self) -> int: + """Обратная совместимость: получение used_traffic_bytes из user_traffic""" + if self.user_traffic: + return self.user_traffic.used_traffic_bytes + return 0 + + @property + def lifetime_used_traffic_bytes(self) -> int: + """Обратная совместимость: получение lifetime_used_traffic_bytes из user_traffic""" + if self.user_traffic: + return self.user_traffic.lifetime_used_traffic_bytes + return 0 + + @property + def online_at(self) -> Optional[datetime]: + """Обратная совместимость: получение online_at из user_traffic""" + if self.user_traffic: + return self.user_traffic.online_at + return None + + @property + def first_connected_at(self) -> Optional[datetime]: + """Обратная совместимость: получение first_connected_at из user_traffic""" + if self.user_traffic: + return self.user_traffic.first_connected_at + return None + + +@dataclass +class RemnaWaveInbound: + """Структура inbound для Internal Squad""" + uuid: str + profile_uuid: str + tag: str + type: str + network: Optional[str] = None + security: Optional[str] = None + port: Optional[int] = None + raw_inbound: Optional[Any] = None @dataclass @@ -67,7 +117,21 @@ class RemnaWaveInternalSquad: name: str members_count: int inbounds_count: int - inbounds: List[Dict[str, Any]] + inbounds: List[RemnaWaveInbound] + view_position: int = 0 + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +@dataclass +class RemnaWaveAccessibleNode: + """Доступная нода для Internal Squad""" + uuid: str + node_name: str + country_code: str + config_profile_uuid: str + config_profile_name: str + active_inbounds: List[str] @dataclass @@ -78,11 +142,39 @@ class RemnaWaveNode: country_code: str is_connected: bool is_disabled: bool - is_node_online: bool - is_xray_running: bool users_online: Optional[int] traffic_used_bytes: Optional[int] traffic_limit_bytes: Optional[int] + port: Optional[int] = None + is_connecting: bool = False + xray_version: Optional[str] = None + node_version: Optional[str] = None + view_position: int = 0 + tags: Optional[List[str]] = None + # Новые поля API + last_status_change: Optional[datetime] = None + last_status_message: Optional[str] = None + xray_uptime: Optional[str] = None + is_traffic_tracking_active: bool = False + traffic_reset_day: Optional[int] = None + notify_percent: Optional[int] = None + consumption_multiplier: float = 1.0 + cpu_count: Optional[int] = None + cpu_model: Optional[str] = None + total_ram: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + provider_uuid: Optional[str] = None + + @property + def is_node_online(self) -> bool: + """Обратная совместимость: is_node_online = is_connected""" + return self.is_connected + + @property + def is_xray_running(self) -> bool: + """Обратная совместимость: xray работает если нода подключена""" + return self.is_connected @dataclass @@ -444,8 +536,38 @@ class RemnaWaveAPI: async def delete_internal_squad(self, uuid: str) -> bool: response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}') return response['response']['isDeleted'] - - + + async def get_internal_squad_accessible_nodes(self, uuid: str) -> List[RemnaWaveAccessibleNode]: + """Получает список доступных нод для Internal Squad""" + try: + response = await self._make_request('GET', f'/api/internal-squads/{uuid}/accessible-nodes') + return [self._parse_accessible_node(node) for node in response['response']['accessibleNodes']] + except RemnaWaveAPIError as e: + if e.status_code == 404: + return [] + raise + + async def add_users_to_internal_squad(self, uuid: str) -> bool: + """Добавляет всех пользователей в Internal Squad (bulk action)""" + response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/add-users') + return response['response']['eventSent'] + + async def remove_users_from_internal_squad(self, uuid: str) -> bool: + """Удаляет всех пользователей из Internal Squad (bulk action)""" + response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/remove-users') + return response['response']['eventSent'] + + async def reorder_internal_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveInternalSquad]: + """ + Изменяет порядок Internal Squads + items: список словарей с uuid и viewPosition + Пример: [{'uuid': '...', 'viewPosition': 0}, {'uuid': '...', 'viewPosition': 1}] + """ + data = {'items': items} + response = await self._make_request('POST', '/api/internal-squads/actions/reorder', data) + return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']] + + async def get_all_nodes(self) -> List[RemnaWaveNode]: response = await self._make_request('GET', '/api/nodes') return [self._parse_node(node) for node in response['response']] @@ -586,42 +708,73 @@ class RemnaWaveAPI: return False + def _parse_user_traffic(self, traffic_data: Optional[Dict]) -> Optional[UserTraffic]: + """Парсит данные трафика из нового формата API""" + if not traffic_data: + return None + + return UserTraffic( + used_traffic_bytes=int(traffic_data.get('usedTrafficBytes', 0)), + lifetime_used_traffic_bytes=int(traffic_data.get('lifetimeUsedTrafficBytes', 0)), + online_at=self._parse_optional_datetime(traffic_data.get('onlineAt')), + first_connected_at=self._parse_optional_datetime(traffic_data.get('firstConnectedAt')), + last_connected_node_uuid=traffic_data.get('lastConnectedNodeUuid') + ) + def _parse_user(self, user_data: Dict) -> RemnaWaveUser: happ_data = user_data.get('happ') or {} happ_link = happ_data.get('link') or happ_data.get('url') happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link') + # Парсим userTraffic из нового формата API + user_traffic = self._parse_user_traffic(user_data.get('userTraffic')) + + # Получаем status с fallback на ACTIVE + status_str = user_data.get('status') or 'ACTIVE' + try: + status = UserStatus(status_str) + except ValueError: + logger.warning(f"Неизвестный статус пользователя: {status_str}, используем ACTIVE") + status = UserStatus.ACTIVE + + # Получаем trafficLimitStrategy с fallback + strategy_str = user_data.get('trafficLimitStrategy') or 'NO_RESET' + try: + traffic_strategy = TrafficLimitStrategy(strategy_str) + except ValueError: + logger.warning(f"Неизвестная стратегия трафика: {strategy_str}, используем NO_RESET") + traffic_strategy = TrafficLimitStrategy.NO_RESET + return RemnaWaveUser( uuid=user_data['uuid'], short_uuid=user_data['shortUuid'], username=user_data['username'], - status=UserStatus(user_data['status']), - used_traffic_bytes=int(user_data['usedTrafficBytes']), - lifetime_used_traffic_bytes=int(user_data['lifetimeUsedTrafficBytes']), - traffic_limit_bytes=user_data['trafficLimitBytes'], - traffic_limit_strategy=TrafficLimitStrategy(user_data['trafficLimitStrategy']), + status=status, + traffic_limit_bytes=user_data.get('trafficLimitBytes', 0), + traffic_limit_strategy=traffic_strategy, expire_at=datetime.fromisoformat(user_data['expireAt'].replace('Z', '+00:00')), telegram_id=user_data.get('telegramId'), email=user_data.get('email'), hwid_device_limit=user_data.get('hwidDeviceLimit'), description=user_data.get('description'), tag=user_data.get('tag'), - subscription_url=user_data['subscriptionUrl'], - active_internal_squads=user_data['activeInternalSquads'], + subscription_url=user_data.get('subscriptionUrl', ''), + active_internal_squads=user_data.get('activeInternalSquads', []), created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')), updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')), + user_traffic=user_traffic, sub_last_user_agent=user_data.get('subLastUserAgent'), sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')), - online_at=self._parse_optional_datetime(user_data.get('onlineAt')), sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')), last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')), trojan_password=user_data.get('trojanPassword'), vless_uuid=user_data.get('vlessUuid'), ss_password=user_data.get('ssPassword'), - first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')), last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0), happ_link=happ_link, - happ_crypto_link=happ_crypto_link + happ_crypto_link=happ_crypto_link, + external_squad_uuid=user_data.get('externalSquadUuid'), + id=user_data.get('id') ) def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]: @@ -629,28 +782,76 @@ class RemnaWaveAPI: return datetime.fromisoformat(date_str.replace('Z', '+00:00')) return None + def _parse_inbound(self, inbound_data: Dict) -> RemnaWaveInbound: + """Парсит данные inbound""" + return RemnaWaveInbound( + uuid=inbound_data['uuid'], + profile_uuid=inbound_data['profileUuid'], + tag=inbound_data['tag'], + type=inbound_data['type'], + network=inbound_data.get('network'), + security=inbound_data.get('security'), + port=inbound_data.get('port'), + raw_inbound=inbound_data.get('rawInbound') + ) + def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad: + info = squad_data.get('info', {}) + inbounds_raw = squad_data.get('inbounds', []) + inbounds = [self._parse_inbound(ib) for ib in inbounds_raw] if inbounds_raw else [] return RemnaWaveInternalSquad( uuid=squad_data['uuid'], name=squad_data['name'], - members_count=squad_data['info']['membersCount'], - inbounds_count=squad_data['info']['inboundsCount'], - inbounds=squad_data['inbounds'] + members_count=info.get('membersCount', 0), + inbounds_count=info.get('inboundsCount', 0), + inbounds=inbounds, + view_position=squad_data.get('viewPosition', 0), + created_at=self._parse_optional_datetime(squad_data.get('createdAt')), + updated_at=self._parse_optional_datetime(squad_data.get('updatedAt')) ) - + + def _parse_accessible_node(self, node_data: Dict) -> RemnaWaveAccessibleNode: + """Парсит данные доступной ноды для Internal Squad""" + return RemnaWaveAccessibleNode( + uuid=node_data['uuid'], + node_name=node_data['nodeName'], + country_code=node_data['countryCode'], + config_profile_uuid=node_data['configProfileUuid'], + config_profile_name=node_data['configProfileName'], + active_inbounds=node_data.get('activeInbounds', []) + ) + def _parse_node(self, node_data: Dict) -> RemnaWaveNode: return RemnaWaveNode( uuid=node_data['uuid'], name=node_data['name'], address=node_data['address'], - country_code=node_data['countryCode'], - is_connected=node_data['isConnected'], - is_disabled=node_data['isDisabled'], - is_node_online=node_data['isNodeOnline'], - is_xray_running=node_data['isXrayRunning'], + country_code=node_data.get('countryCode', ''), + is_connected=node_data.get('isConnected', False), + is_disabled=node_data.get('isDisabled', False), users_online=node_data.get('usersOnline'), traffic_used_bytes=node_data.get('trafficUsedBytes'), - traffic_limit_bytes=node_data.get('trafficLimitBytes') + traffic_limit_bytes=node_data.get('trafficLimitBytes'), + port=node_data.get('port'), + is_connecting=node_data.get('isConnecting', False), + xray_version=node_data.get('xrayVersion'), + node_version=node_data.get('nodeVersion'), + view_position=node_data.get('viewPosition', 0), + tags=node_data.get('tags', []), + # Новые поля API + last_status_change=self._parse_optional_datetime(node_data.get('lastStatusChange')), + last_status_message=node_data.get('lastStatusMessage'), + xray_uptime=node_data.get('xrayUptime'), + is_traffic_tracking_active=node_data.get('isTrafficTrackingActive', False), + traffic_reset_day=node_data.get('trafficResetDay'), + notify_percent=node_data.get('notifyPercent'), + consumption_multiplier=node_data.get('consumptionMultiplier', 1.0), + cpu_count=node_data.get('cpuCount'), + cpu_model=node_data.get('cpuModel'), + total_ram=node_data.get('totalRam'), + created_at=self._parse_optional_datetime(node_data.get('createdAt')), + updated_at=self._parse_optional_datetime(node_data.get('updatedAt')), + provider_uuid=node_data.get('providerUuid') ) def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo: diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py index 968a56f2..c0c17fbf 100644 --- a/app/handlers/admin/remnawave.py +++ b/app/handlers/admin/remnawave.py @@ -1316,7 +1316,29 @@ async def show_node_details( status_emoji = "🟢" if node["is_node_online"] else "🔴" xray_emoji = "✅" if node["is_xray_running"] else "❌" - + + status_change = ( + format_datetime(node["last_status_change"]) + if node.get("last_status_change") + else "—" + ) + created_at = ( + format_datetime(node["created_at"]) + if node.get("created_at") + else "—" + ) + updated_at = ( + format_datetime(node["updated_at"]) + if node.get("updated_at") + else "—" + ) + notify_percent = ( + f"{node['notify_percent']}%" if node.get("notify_percent") is not None else "—" + ) + cpu_info = node.get("cpu_model") or "—" + if node.get("cpu_count"): + cpu_info = f"{node['cpu_count']}x {cpu_info}" + text = f""" 🖥️ Нода: {node['name']} @@ -1325,15 +1347,29 @@ async def show_node_details( - Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'} - Подключена: {'📡 Да' if node['is_connected'] else '📵 Нет'} - Отключена: {'❌ Да' if node['is_disabled'] else '✅ Нет'} +- Изменение статуса: {status_change} +- Сообщение: {node.get('last_status_message') or '—'} +- Uptime Xray: {node.get('xray_uptime') or '—'} Информация: - Адрес: {node['address']} - Страна: {node['country_code']} - Пользователей онлайн: {node['users_online']} +- CPU: {cpu_info} +- RAM: {node.get('total_ram') or '—'} +- Провайдер: {node.get('provider_uuid') or '—'} Трафик: - Использовано: {format_bytes(node['traffic_used_bytes'])} - Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'} +- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'} +- День сброса: {node.get('traffic_reset_day') or '—'} +- Уведомления: {notify_percent} +- Множитель: {node.get('consumption_multiplier') or 1} + +Метаданные: +- Создана: {created_at} +- Обновлена: {updated_at} """ await callback.message.edit_text( @@ -1350,28 +1386,18 @@ async def manage_node( db_user: User, db: AsyncSession ): - action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1] - - remnawave_service = RemnaWaveService() - success = await remnawave_service.manage_node(node_uuid, action) - - if success: - action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"} - await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}") - else: - await callback.answer("❌ Ошибка выполнения действия", show_alert=True) - - await show_node_details( - types.CallbackQuery( - id=callback.id, - from_user=callback.from_user, - chat_instance=callback.chat_instance, - data=f"admin_node_manage_{node_uuid}", - message=callback.message - ), - db_user, - db - ) + action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1] + + remnawave_service = RemnaWaveService() + success = await remnawave_service.manage_node(node_uuid, action) + + if success: + action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"} + await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}") + else: + await callback.answer("❌ Ошибка выполнения действия", show_alert=True) + + await show_node_details(callback, db_user, db) @admin_required @error_handler @@ -1407,10 +1433,32 @@ async def show_node_statistics( if stats.get('nodeUuid') == node_uuid: node_realtime = stats break - + + status_change = ( + format_datetime(node["last_status_change"]) + if node.get("last_status_change") + else "—" + ) + created_at = ( + format_datetime(node["created_at"]) + if node.get("created_at") + else "—" + ) + updated_at = ( + format_datetime(node["updated_at"]) + if node.get("updated_at") + else "—" + ) + notify_percent = ( + f"{node['notify_percent']}%" if node.get("notify_percent") is not None else "—" + ) + cpu_info = node.get("cpu_model") or "—" + if node.get("cpu_count"): + cpu_info = f"{node['cpu_count']}x {cpu_info}" + status_emoji = "🟢" if node["is_node_online"] else "🔴" xray_emoji = "✅" if node["is_xray_running"] else "❌" - + text = f""" 📊 Статистика ноды: {node['name']} @@ -1418,10 +1466,26 @@ async def show_node_statistics( - Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'} - Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'} - Пользователей онлайн: {node['users_online'] or 0} +- Изменение статуса: {status_change} +- Сообщение: {node.get('last_status_message') or '—'} +- Uptime Xray: {node.get('xray_uptime') or '—'} + +Ресурсы: +- CPU: {cpu_info} +- RAM: {node.get('total_ram') or '—'} +- Провайдер: {node.get('provider_uuid') or '—'} Трафик: - Использовано: {format_bytes(node['traffic_used_bytes'] or 0)} - Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'} +- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'} +- День сброса: {node.get('traffic_reset_day') or '—'} +- Уведомления: {notify_percent} +- Множитель: {node.get('consumption_multiplier') or 1} + +Метаданные: +- Создана: {created_at} +- Обновлена: {updated_at} """ if node_realtime: @@ -1456,18 +1520,25 @@ async def show_node_statistics( except Exception as e: logger.error(f"Ошибка получения статистики ноды {node_uuid}: {e}") - + text = f""" 📊 Статистика ноды: {node['name']} Статус: -- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'} +- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'} - Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'} - Пользователей онлайн: {node['users_online'] or 0} +- Изменение статуса: {format_datetime(node.get('last_status_change')) if node.get('last_status_change') else '—'} +- Сообщение: {node.get('last_status_message') or '—'} +- Uptime Xray: {node.get('xray_uptime') or '—'} Трафик: - Использовано: {format_bytes(node['traffic_used_bytes'] or 0)} - Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'} +- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'} +- День сброса: {node.get('traffic_reset_day') or '—'} +- Уведомления: {node.get('notify_percent') or '—'} +- Множитель: {node.get('consumption_multiplier') or 1} ⚠️ Детальная статистика временно недоступна Возможные причины: @@ -1566,17 +1637,11 @@ async def manage_squad_action( await callback.answer("❌ Ошибка удаления сквада", show_alert=True) return - await show_squad_details( - types.CallbackQuery( - id=callback.id, - from_user=callback.from_user, - chat_instance=callback.chat_instance, - data=f"admin_squad_manage_{squad_uuid}", - message=callback.message - ), - db_user, - db - ) + refreshed_callback = callback.model_copy( + update={"data": f"admin_squad_manage_{squad_uuid}"} + ).as_(callback.bot) + + await show_squad_details(refreshed_callback, db_user, db) @admin_required @error_handler @@ -1734,15 +1799,11 @@ async def cancel_squad_rename( await state.clear() - new_callback = types.CallbackQuery( - id=callback.id, - from_user=callback.from_user, - chat_instance=callback.chat_instance, - data=f"squad_edit_{squad_uuid}", - message=callback.message - ) - - await show_squad_edit_menu(new_callback, db_user, db) + refreshed_callback = callback.model_copy( + update={"data": f"squad_edit_{squad_uuid}"} + ).as_(callback.bot) + + await show_squad_edit_menu(refreshed_callback, db_user, db) @admin_required @error_handler @@ -1953,15 +2014,11 @@ async def show_squad_edit_menu_short( await callback.answer("❌ Сквад не найден", show_alert=True) return - new_callback = types.CallbackQuery( - id=callback.id, - from_user=callback.from_user, - chat_instance=callback.chat_instance, - data=f"squad_edit_{full_squad_uuid}", - message=callback.message - ) - - await show_squad_edit_menu(new_callback, db_user, db) + refreshed_callback = callback.model_copy( + update={"data": f"squad_edit_{full_squad_uuid}"} + ).as_(callback.bot) + + await show_squad_edit_menu(refreshed_callback, db_user, db) @admin_required @error_handler diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 40d90797..001b7b54 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -3,6 +3,7 @@ import logging import re from contextlib import AsyncExitStack, asynccontextmanager from datetime import datetime, timedelta +from dataclasses import asdict, is_dataclass from typing import Any, Dict, List, Optional, Tuple from zoneinfo import ZoneInfo @@ -46,6 +47,26 @@ from app.utils.timezone import get_local_timezone logger = logging.getLogger(__name__) +def _get_user_traffic_bytes(panel_user: Dict[str, Any]) -> int: + """Извлекает usedTrafficBytes из панельного пользователя (совместимо с новым и старым API)""" + # Новый формат: userTraffic.usedTrafficBytes + user_traffic = panel_user.get('userTraffic') + if user_traffic and isinstance(user_traffic, dict): + return user_traffic.get('usedTrafficBytes', 0) + # Старый формат: usedTrafficBytes напрямую + return panel_user.get('usedTrafficBytes', 0) + + +def _get_lifetime_traffic_bytes(panel_user: Dict[str, Any]) -> int: + """Извлекает lifetimeUsedTrafficBytes из панельного пользователя (совместимо с новым и старым API)""" + # Новый формат: userTraffic.lifetimeUsedTrafficBytes + user_traffic = panel_user.get('userTraffic') + if user_traffic and isinstance(user_traffic, dict): + return user_traffic.get('lifetimeUsedTrafficBytes', 0) + # Старый формат: lifetimeUsedTrafficBytes напрямую + return panel_user.get('lifetimeUsedTrafficBytes', 0) + + _UUID_MAP_MISSING = object() @@ -760,7 +781,20 @@ class RemnaWaveService: "is_xray_running": node.is_xray_running, "users_online": node.users_online or 0, "traffic_used_bytes": node.traffic_used_bytes or 0, - "traffic_limit_bytes": node.traffic_limit_bytes or 0 + "traffic_limit_bytes": node.traffic_limit_bytes or 0, + "last_status_change": node.last_status_change, + "last_status_message": node.last_status_message, + "xray_uptime": node.xray_uptime, + "is_traffic_tracking_active": node.is_traffic_tracking_active, + "traffic_reset_day": node.traffic_reset_day, + "notify_percent": node.notify_percent, + "consumption_multiplier": node.consumption_multiplier, + "cpu_count": node.cpu_count, + "cpu_model": node.cpu_model, + "total_ram": node.total_ram, + "created_at": node.created_at, + "updated_at": node.updated_at, + "provider_uuid": node.provider_uuid, } except Exception as e: @@ -818,15 +852,19 @@ class RemnaWaveService: try: async with self.get_api_client() as api: squads = await api.get_internal_squads() - + result = [] for squad in squads: + inbounds = [ + asdict(inbound) if is_dataclass(inbound) else inbound + for inbound in squad.inbounds or [] + ] result.append({ 'uuid': squad.uuid, 'name': squad.name, 'members_count': squad.members_count, 'inbounds_count': squad.inbounds_count, - 'inbounds': squad.inbounds + 'inbounds': inbounds, }) logger.info(f"✅ Получено {len(result)} сквадов из Remnawave") @@ -1463,10 +1501,10 @@ class RemnaWaveService: traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0) traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0 - - used_traffic_bytes = panel_user.get('usedTrafficBytes', 0) + + used_traffic_bytes = _get_user_traffic_bytes(panel_user) traffic_used_gb = used_traffic_bytes / (1024**3) - + active_squads = panel_user.get('activeInternalSquads', []) squad_uuids = [] if isinstance(active_squads, list): @@ -1568,10 +1606,10 @@ class RemnaWaveService: if subscription.status != new_status: subscription.status = new_status logger.debug(f"Обновлен статус подписки: {new_status}") - - used_traffic_bytes = panel_user.get('usedTrafficBytes', 0) + + used_traffic_bytes = _get_user_traffic_bytes(panel_user) traffic_used_gb = used_traffic_bytes / (1024**3) - + if abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01: subscription.traffic_used_gb = traffic_used_gb logger.debug(f"Обновлен использованный трафик: {traffic_used_gb} GB") @@ -1827,12 +1865,16 @@ class RemnaWaveService: async with self.get_api_client() as api: squad = await api.get_internal_squad_by_uuid(squad_uuid) if squad: + inbounds = [ + asdict(inbound) if is_dataclass(inbound) else inbound + for inbound in squad.inbounds or [] + ] return { 'uuid': squad.uuid, 'name': squad.name, 'members_count': squad.members_count, 'inbounds_count': squad.inbounds_count, - 'inbounds': squad.inbounds + 'inbounds': inbounds } return None except Exception as e: diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 623aeaf9..f7a29e2f 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -132,6 +132,13 @@ class SubscriptionService: self._last_config_signature = config_signature + @staticmethod + def _resolve_user_tag(subscription: Subscription) -> Optional[str]: + if getattr(subscription, "is_trial", False): + return settings.get_trial_user_tag() + + return settings.get_paid_subscription_user_tag() + @property def is_configured(self) -> bool: return self._config_error is None @@ -173,7 +180,9 @@ class SubscriptionService: if not validation_success: logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}") return None - + + user_tag = self._resolve_user_tag(subscription) + async with self.get_api_client() as api: hwid_limit = resolve_hwid_device_limit_for_payload(subscription) existing_users = await api.get_user_by_telegram_id(user.telegram_id) @@ -201,6 +210,9 @@ class SubscriptionService: active_internal_squads=subscription.connected_squads, ) + if user_tag is not None: + update_kwargs['tag'] = user_tag + if hwid_limit is not None: update_kwargs['hwid_device_limit'] = hwid_limit @@ -236,6 +248,9 @@ class SubscriptionService: active_internal_squads=subscription.connected_squads, ) + if user_tag is not None: + create_kwargs['tag'] = user_tag + if hwid_limit is not None: create_kwargs['hwid_device_limit'] = hwid_limit @@ -288,15 +303,17 @@ class SubscriptionService: is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > current_time) - if (subscription.status == SubscriptionStatus.ACTIVE.value and + if (subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time): - + subscription.status = SubscriptionStatus.EXPIRED.value subscription.updated_at = current_time await db.commit() is_actually_active = False logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'") - + + user_tag = self._resolve_user_tag(subscription) + async with self.get_api_client() as api: hwid_limit = resolve_hwid_device_limit_for_payload(subscription) @@ -314,6 +331,9 @@ class SubscriptionService: active_internal_squads=subscription.connected_squads, ) + if user_tag is not None: + update_kwargs['tag'] = user_tag + if hwid_limit is not None: update_kwargs['hwid_device_limit'] = hwid_limit diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index c8426e21..7b78034a 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -220,6 +220,7 @@ class BotConfigurationService: "PRICE_90_DAYS": "SUBSCRIPTION_PRICES", "PRICE_180_DAYS": "SUBSCRIPTION_PRICES", "PRICE_360_DAYS": "SUBSCRIPTION_PRICES", + "PAID_SUBSCRIPTION_USER_TAG": "SUBSCRIPTION_PRICES", "TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES", "BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE", "BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE", @@ -227,6 +228,7 @@ class BotConfigurationService: "DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY", "MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY", "TRIAL_WARNING_HOURS": "TRIAL", + "TRIAL_USER_TAG": "TRIAL", "SUPPORT_USERNAME": "SUPPORT", "SUPPORT_MENU_ENABLED": "SUPPORT", "SUPPORT_SYSTEM_MODE": "SUPPORT", @@ -643,6 +645,24 @@ class BotConfigurationService: "warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.", "dependencies": "Результат вызова getMe() в Telegram Bot API", }, + "TRIAL_USER_TAG": { + "description": ( + "Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave." + ), + "format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.", + "example": "TRIAL_USER", + "warning": "Неверный формат будет проигнорирован при создании пользователя.", + "dependencies": "Активация триала и включенная интеграция с RemnaWave", + }, + "PAID_SUBSCRIPTION_USER_TAG": { + "description": ( + "Тег, который бот ставит пользователю при покупке платной подписки в панели RemnaWave." + ), + "format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.", + "example": "PAID_USER", + "warning": "Если тег не задан или невалиден, существующий тег не будет изменён.", + "dependencies": "Оплата подписки и интеграция с RemnaWave", + }, } @classmethod diff --git a/app/webapi/routes/remnawave.py b/app/webapi/routes/remnawave.py index 421db6d3..b85193d6 100644 --- a/app/webapi/routes/remnawave.py +++ b/app/webapi/routes/remnawave.py @@ -93,6 +93,19 @@ def _serialize_node(node_data: Dict[str, Any]) -> RemnaWaveNode: users_online=node_data.get("users_online"), traffic_used_bytes=node_data.get("traffic_used_bytes"), traffic_limit_bytes=node_data.get("traffic_limit_bytes"), + last_status_change=_parse_last_updated(node_data.get("last_status_change")), + last_status_message=node_data.get("last_status_message"), + xray_uptime=node_data.get("xray_uptime"), + is_traffic_tracking_active=bool(node_data.get("is_traffic_tracking_active", False)), + traffic_reset_day=node_data.get("traffic_reset_day"), + notify_percent=node_data.get("notify_percent"), + consumption_multiplier=float(node_data.get("consumption_multiplier", 1.0)), + cpu_count=node_data.get("cpu_count"), + cpu_model=node_data.get("cpu_model"), + total_ram=node_data.get("total_ram"), + created_at=_parse_last_updated(node_data.get("created_at")), + updated_at=_parse_last_updated(node_data.get("updated_at")), + provider_uuid=node_data.get("provider_uuid"), ) @@ -291,9 +304,13 @@ async def create_squad( service = _get_service() _ensure_service_configured(service) - success = await service.create_squad(payload.name, payload.inbound_uuids) + squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids) + + success = squad_uuid is not None detail = "Сквад успешно создан" if success else "Не удалось создать сквад" - return RemnaWaveOperationResponse(success=success, detail=detail) + data = {"uuid": squad_uuid} if success else None + + return RemnaWaveOperationResponse(success=success, detail=detail, data=data) @router.patch("/squads/{squad_uuid}", response_model=RemnaWaveOperationResponse) diff --git a/app/webapi/schemas/remnawave.py b/app/webapi/schemas/remnawave.py index bc41a41c..d640f5bd 100644 --- a/app/webapi/schemas/remnawave.py +++ b/app/webapi/schemas/remnawave.py @@ -32,6 +32,19 @@ class RemnaWaveNode(BaseModel): users_online: Optional[int] = None traffic_used_bytes: Optional[int] = None traffic_limit_bytes: Optional[int] = None + last_status_change: Optional[datetime] = None + last_status_message: Optional[str] = None + xray_uptime: Optional[str] = None + is_traffic_tracking_active: bool = False + traffic_reset_day: Optional[int] = None + notify_percent: Optional[int] = None + consumption_multiplier: float = 1.0 + cpu_count: Optional[int] = None + cpu_model: Optional[str] = None + total_ram: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + provider_uuid: Optional[str] = None class RemnaWaveNodeListResponse(BaseModel):