From f04ffa58e47d3c6917cd4d8bc29e51b06ada125b Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 01:31:42 +0300 Subject: [PATCH 01/19] Update remnawave_api.py --- app/external/remnawave_api.py | 93 ++++++++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 17 deletions(-) diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index abae5463..a813765c 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,47 @@ 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 @@ -68,6 +105,9 @@ class RemnaWaveInternalSquad: members_count: int inbounds_count: int inbounds: List[Dict[str, Any]] + view_position: int = 0 + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None @dataclass @@ -586,42 +626,57 @@ 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')) + 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=UserStatus(user_data.get('status', 'ACTIVE')), + traffic_limit_bytes=user_data.get('trafficLimitBytes', 0), + traffic_limit_strategy=TrafficLimitStrategy(user_data.get('trafficLimitStrategy', 'NO_RESET')), 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]: @@ -630,12 +685,16 @@ class RemnaWaveAPI: return None def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad: + info = squad_data.get('info', {}) 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=squad_data.get('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_node(self, node_data: Dict) -> RemnaWaveNode: From 23eed94009e7fdb155c7b2a70ad46a00100943df Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 01:32:23 +0300 Subject: [PATCH 02/19] Add functions to extract traffic bytes from user data --- app/services/remnawave_service.py | 32 +++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 40d90797..d5223a30 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -46,6 +46,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() @@ -1463,10 +1483,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 +1588,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") From e64854dc4815bf96d6376ce2d8e54936981dd617 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 01:42:03 +0300 Subject: [PATCH 03/19] Update remnawave_api.py --- app/external/remnawave_api.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index a813765c..e72f6a44 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -647,13 +647,29 @@ class RemnaWaveAPI: # Парсим 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.get('status', 'ACTIVE')), + status=status, traffic_limit_bytes=user_data.get('trafficLimitBytes', 0), - traffic_limit_strategy=TrafficLimitStrategy(user_data.get('trafficLimitStrategy', 'NO_RESET')), + 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'), From 386b9ae9986a56070a7a13664fbacae77b90c349 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 02:44:53 +0300 Subject: [PATCH 04/19] Add configurable user tags for trial and paid subscriptions --- app/config.py | 43 +++++++++++++++++++++++-- app/services/subscription_service.py | 28 +++++++++++++--- app/services/system_settings_service.py | 20 ++++++++++++ 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/app/config.py b/app/config.py index cccc32fc..1bf109b2 100644 --- a/app/config.py +++ b/app/config.py @@ -19,6 +19,8 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [ "joingroup", ] +USER_TAG_PATTERN = re.compile(r"^[A-Z0-9_]{1,16}$") + logger = logging.getLogger(__name__) @@ -88,6 +90,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" @@ -119,6 +122,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 @@ -776,13 +780,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/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 From 9b6cd74dbff7b12846365e745c855542c38e838f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 03:01:21 +0300 Subject: [PATCH 05/19] Update remnawave_api.py --- app/external/remnawave_api.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index e72f6a44..222d6694 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -118,11 +118,25 @@ 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 + + @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 @@ -718,14 +732,18 @@ class RemnaWaveAPI: 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', []) ) def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo: From 5a795a2ae2a6eecd6d8fdbb0fa337c19385effbf Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 03:13:16 +0300 Subject: [PATCH 06/19] Update remnawave_api.py --- app/external/remnawave_api.py | 120 ++++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 6 deletions(-) diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index 222d6694..2fccbeb4 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -98,18 +98,42 @@ class RemnaWaveUser: 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 class RemnaWaveInternalSquad: uuid: str 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 class RemnaWaveNode: uuid: str @@ -127,6 +151,20 @@ class RemnaWaveNode: 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: @@ -498,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']] @@ -714,19 +782,45 @@ 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=info.get('membersCount', 0), inbounds_count=info.get('inboundsCount', 0), - inbounds=squad_data.get('inbounds', []), + 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'], @@ -743,7 +837,21 @@ class RemnaWaveAPI: xray_version=node_data.get('xrayVersion'), node_version=node_data.get('nodeVersion'), view_position=node_data.get('viewPosition', 0), - tags=node_data.get('tags', []) + 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: From 988ffbebdbdb6c949c9a9883588aff648d243425 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 03:19:23 +0300 Subject: [PATCH 07/19] Expand Remnawave node statistics --- app/handlers/admin/remnawave.py | 91 +++++++++++++++++++++++++++++-- app/services/remnawave_service.py | 15 ++++- app/webapi/routes/remnawave.py | 13 +++++ app/webapi/schemas/remnawave.py | 13 +++++ 4 files changed, 126 insertions(+), 6 deletions(-) diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py index 968a56f2..70159ba9 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( @@ -1407,10 +1443,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 +1476,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 +1530,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} ⚠️ Детальная статистика временно недоступна Возможные причины: diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index d5223a30..0c95cd10 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -780,7 +780,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: diff --git a/app/webapi/routes/remnawave.py b/app/webapi/routes/remnawave.py index 421db6d3..d0069f36 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"), ) 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): From 0031c9e2e0bb189e9bf9366b26d761fd7861e8ee Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 03:24:13 +0300 Subject: [PATCH 08/19] Fix callback reuse after node actions --- app/handlers/admin/remnawave.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py index 70159ba9..62b1ed23 100644 --- a/app/handlers/admin/remnawave.py +++ b/app/handlers/admin/remnawave.py @@ -1397,14 +1397,12 @@ async def manage_node( else: await callback.answer("❌ Ошибка выполнения действия", show_alert=True) + refreshed_callback = callback.model_copy( + update={"data": f"admin_node_manage_{node_uuid}"} + ) + 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 - ), + refreshed_callback, db_user, db ) From c770948c1f5c154f0ccd04806e6b597bd145c6f9 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 03:26:14 +0300 Subject: [PATCH 09/19] Revert "Fix callback reuse after node actions" --- app/handlers/admin/remnawave.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py index 62b1ed23..70159ba9 100644 --- a/app/handlers/admin/remnawave.py +++ b/app/handlers/admin/remnawave.py @@ -1397,12 +1397,14 @@ async def manage_node( else: await callback.answer("❌ Ошибка выполнения действия", show_alert=True) - refreshed_callback = callback.model_copy( - update={"data": f"admin_node_manage_{node_uuid}"} - ) - await show_node_details( - refreshed_callback, + 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 ) From ff4471a22af626eace7fd446b8328918cb752a32 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 03:26:32 +0300 Subject: [PATCH 10/19] Fix node details refresh callback usage --- app/handlers/admin/remnawave.py | 34 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py index 70159ba9..29a177a9 100644 --- a/app/handlers/admin/remnawave.py +++ b/app/handlers/admin/remnawave.py @@ -1386,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 From 5b4c597e9bfb9ff49167f06edb509aea7ed829bb Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 03:32:58 +0300 Subject: [PATCH 11/19] Revert "Fix node details refresh callback usage" --- app/handlers/admin/remnawave.py | 34 +++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py index 29a177a9..70159ba9 100644 --- a/app/handlers/admin/remnawave.py +++ b/app/handlers/admin/remnawave.py @@ -1386,18 +1386,28 @@ 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(callback, 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( + 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 + ) @admin_required @error_handler From e9e61f4892c93fe22bc22309946f8cb58d7a09ce Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 03:33:56 +0300 Subject: [PATCH 12/19] Bind refreshed callbacks to bot --- app/handlers/admin/remnawave.py | 78 ++++++++++++--------------------- 1 file changed, 27 insertions(+), 51 deletions(-) diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py index 70159ba9..c0c17fbf 100644 --- a/app/handlers/admin/remnawave.py +++ b/app/handlers/admin/remnawave.py @@ -1386,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 @@ -1647,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 @@ -1815,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 @@ -2034,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 From 841c288313795fad828b05fcf54dbce1d2753f4b Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 03:52:38 +0300 Subject: [PATCH 13/19] Fix serialization of squad inbounds --- app/services/remnawave_service.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 0c95cd10..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 @@ -851,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") @@ -1860,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: From 1ef2e1264f71a3005502f914162f90b9ec36bbeb Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 04:00:22 +0300 Subject: [PATCH 14/19] Revert "Fix RemnaWave squad inbound serialization" --- app/services/remnawave_service.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 001b7b54..0c95cd10 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -3,7 +3,6 @@ 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 @@ -852,19 +851,15 @@ 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': inbounds, + 'inbounds': squad.inbounds }) logger.info(f"✅ Получено {len(result)} сквадов из Remnawave") @@ -1865,16 +1860,12 @@ 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': inbounds + 'inbounds': squad.inbounds } return None except Exception as e: From d596b19d967fb7e75f6df4cf918ce49a9ef05900 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 04:00:42 +0300 Subject: [PATCH 15/19] Fix RemnaWave squad creation success flag --- app/services/remnawave_service.py | 15 ++++++++++++--- app/webapi/routes/remnawave.py | 8 ++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 0c95cd10..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 @@ -851,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") @@ -1860,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/webapi/routes/remnawave.py b/app/webapi/routes/remnawave.py index d0069f36..b85193d6 100644 --- a/app/webapi/routes/remnawave.py +++ b/app/webapi/routes/remnawave.py @@ -304,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) From 6c41263511eb11bdce5aace4b364a5d5c6e220bb Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 04:15:18 +0300 Subject: [PATCH 16/19] Add internal squad management for users and trials --- app/config.py | 23 +++++++ app/database/crud/subscription.py | 36 ++++++++++- app/database/crud/user.py | 32 +++++++++- app/database/models.py | 1 + app/database/universal_migration.py | 42 +++++++++++++ app/handlers/admin/users.py | 12 +++- app/services/monitoring_service.py | 6 +- app/services/remnawave_service.py | 67 ++++++++++++++------ app/services/subscription_service.py | 81 +++++++++++++++++++++++-- app/services/system_settings_service.py | 9 +++ app/webapi/routes/remnawave.py | 48 ++++++++++++++- app/webapi/routes/users.py | 5 ++ app/webapi/schemas/remnawave.py | 14 +++++ app/webapi/schemas/users.py | 3 + 14 files changed, 349 insertions(+), 30 deletions(-) diff --git a/app/config.py b/app/config.py index 1bf109b2..196f9817 100644 --- a/app/config.py +++ b/app/config.py @@ -91,6 +91,7 @@ class Settings(BaseSettings): TRIAL_PAYMENT_ENABLED: bool = False TRIAL_ACTIVATION_PRICE: int = 0 TRIAL_USER_TAG: Optional[str] = None + TRIAL_INTERNAL_SQUADS: Optional[str] = None DEFAULT_TRAFFIC_LIMIT_GB: int = 100 DEFAULT_DEVICE_LIMIT: int = 1 DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH" @@ -816,6 +817,28 @@ class Settings(BaseSettings): def get_trial_user_tag(self) -> Optional[str]: return self._normalize_user_tag(self.TRIAL_USER_TAG, "TRIAL_USER_TAG") + def get_trial_internal_squads(self) -> list[str]: + raw_value = self.TRIAL_INTERNAL_SQUADS + if raw_value is None: + return [] + + if isinstance(raw_value, str): + items = [item.strip() for item in re.split(r"[,\n]", raw_value) if item.strip()] + elif isinstance(raw_value, (list, tuple, set)): + items = [str(item).strip() for item in raw_value if str(item).strip()] + else: + return [] + + seen = set() + unique_items: list[str] = [] + for item in items: + lowered = item.lower() + if lowered in seen: + continue + seen.add(lowered) + unique_items.append(item) + return unique_items + def get_paid_subscription_user_tag(self) -> Optional[str]: return self._normalize_user_tag( self.PAID_SUBSCRIPTION_USER_TAG, diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 0ba9f72e..4693f478 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -72,6 +72,20 @@ async def create_trial_subscription( end_date = datetime.utcnow() + timedelta(days=duration_days) + trial_internal_squads = settings.get_trial_internal_squads() + trial_user: Optional[User] = None + if trial_internal_squads: + try: + trial_user = await db.get(User, user_id) + if trial_user is not None: + trial_user.active_internal_squads = trial_internal_squads + except Exception as error: + logger.warning( + "Не удалось применить internal squads для триала пользователя %s: %s", + user_id, + error, + ) + subscription = Subscription( user_id=user_id, status=SubscriptionStatus.ACTIVE.value, @@ -131,10 +145,30 @@ async def create_paid_subscription( ) -> Subscription: end_date = datetime.utcnow() + timedelta(days=duration_days) - + if device_limit is None: device_limit = settings.DEFAULT_DEVICE_LIMIT + trial_internal_squads = settings.get_trial_internal_squads() + if trial_internal_squads: + try: + paid_user = await db.get(User, user_id) + if paid_user and paid_user.active_internal_squads: + current_set = { + str(item).strip().lower() + for item in paid_user.active_internal_squads + if str(item).strip() + } + trial_set = {item.lower() for item in trial_internal_squads} + if current_set == trial_set: + paid_user.active_internal_squads = [] + except Exception as error: + logger.warning( + "Не удалось сбросить trial internal squads при покупке для пользователя %s: %s", + user_id, + error, + ) + subscription = Subscription( user_id=user_id, status=SubscriptionStatus.ACTIVE.value, diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 2f944df2..88ef9798 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -2,7 +2,7 @@ import logging import secrets import string from datetime import datetime, timedelta -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Iterable from sqlalchemy import select, and_, or_, func, case, nullslast, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload, joinedload @@ -34,6 +34,26 @@ def generate_referral_code() -> str: return f"ref{code_suffix}" +def _normalize_internal_squads(value: Optional[Iterable[str]]) -> Optional[list[str]]: + if value is None: + return None + + try: + items = [str(item).strip() for item in value if str(item).strip()] + except TypeError: + return None + + seen = set() + normalized: list[str] = [] + for item in items: + lowered = item.lower() + if lowered in seen: + continue + seen.add(lowered) + normalized.append(item) + return normalized + + async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]: result = await db.execute( select(User) @@ -171,7 +191,8 @@ async def create_user_no_commit( last_name: str = None, language: str = "ru", referred_by_id: int = None, - referral_code: str = None + referral_code: str = None, + active_internal_squads: Optional[Iterable[str]] = None, ) -> User: """ Создает пользователя без немедленного коммита для пакетной обработки @@ -197,6 +218,7 @@ async def create_user_no_commit( has_had_paid_subscription=False, has_made_first_topup=False, promo_group_id=promo_group_id, + active_internal_squads=_normalize_internal_squads(active_internal_squads), ) db.add(user) @@ -222,7 +244,8 @@ async def create_user( last_name: str = None, language: str = "ru", referred_by_id: int = None, - referral_code: str = None + referral_code: str = None, + active_internal_squads: Optional[Iterable[str]] = None, ) -> User: if not referral_code: @@ -248,6 +271,7 @@ async def create_user( has_had_paid_subscription=False, has_made_first_topup=False, promo_group_id=promo_group_id, + active_internal_squads=_normalize_internal_squads(active_internal_squads), ) db.add(user) @@ -295,6 +319,8 @@ async def update_user( for field, value in kwargs.items(): if field in ("first_name", "last_name"): value = sanitize_telegram_name(value) + if field == "active_internal_squads": + value = _normalize_internal_squads(value) if hasattr(user, field): setattr(user, field, value) diff --git a/app/database/models.py b/app/database/models.py index f8e9f719..d3164d86 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -600,6 +600,7 @@ class User(Base): promo_offer_discount_source = Column(String(100), nullable=True) promo_offer_discount_expires_at = Column(DateTime, nullable=True) last_remnawave_sync = Column(DateTime, nullable=True) + active_internal_squads = Column(JSON, nullable=True) trojan_password = Column(String(255), nullable=True) vless_uuid = Column(String(255), nullable=True) ss_password = Column(String(255), nullable=True) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 6bf30aff..1d9d4cb4 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1489,6 +1489,40 @@ async def ensure_user_promo_offer_discount_columns(): return False +async def ensure_user_internal_squads_column() -> bool: + try: + column_exists = await check_column_exists('users', 'active_internal_squads') + if column_exists: + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + column_def = 'JSON NULL' + elif db_type == 'postgresql': + column_def = 'JSONB NULL' + elif db_type == 'mysql': + column_def = 'JSON NULL' + else: + raise ValueError(f"Unsupported database type: {db_type}") + + await conn.execute( + text( + f"ALTER TABLE users ADD COLUMN active_internal_squads {column_def}" + ) + ) + + logger.info("✅ Добавлена колонка active_internal_squads в таблицу users") + return True + except Exception as error: + logger.error( + "Ошибка добавления колонки active_internal_squads в users: %s", + error, + ) + return False + + async def ensure_promo_offer_template_active_duration_column() -> bool: try: column_exists = await check_column_exists('promo_offer_templates', 'active_discount_hours') @@ -3967,6 +4001,12 @@ async def run_universal_migration(): else: logger.warning("⚠️ Не удалось обновить пользовательские промо-скидки") + internal_squads_ready = await ensure_user_internal_squads_column() + if internal_squads_ready: + logger.info("✅ Колонка active_internal_squads для users готова") + else: + logger.warning("⚠️ Не удалось обновить колонку active_internal_squads для users") + effect_types_updated = await migrate_discount_offer_effect_types() if effect_types_updated: logger.info("✅ Типы эффектов промо-предложений обновлены") @@ -4267,6 +4307,7 @@ async def check_migration_status(): "promo_offer_templates_active_discount_column": False, "promo_offer_logs_table": False, "subscription_temporary_access_table": False, + "users_active_internal_squads_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -4303,6 +4344,7 @@ async def check_migration_status(): status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at') status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') + status["users_active_internal_squads_column"] = await check_column_exists('users', 'active_internal_squads') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index d25499e6..9b0c4657 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -4606,7 +4606,11 @@ async def admin_buy_subscription_execute( username=target_user.username, telegram_id=target_user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=( + list(target_user.active_internal_squads or []) + if target_user.active_internal_squads is not None + else list(subscription.connected_squads or []) + ), ) if hwid_limit is not None: @@ -4632,7 +4636,11 @@ async def admin_buy_subscription_execute( username=target_user.username, telegram_id=target_user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=( + list(target_user.active_internal_squads or []) + if target_user.active_internal_squads is not None + else list(subscription.connected_squads or []) + ), ) if hwid_limit is not None: diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 9a0f6f95..864bb2be 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -312,7 +312,11 @@ class MonitoringService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=( + list(user.active_internal_squads or []) + if user.active_internal_squads is not None + else list(subscription.connected_squads or []) + ), ) if hwid_limit is not None: diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 001b7b54..468645d3 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -846,33 +846,60 @@ class RemnaWaveService: except Exception as e: logger.error(f"Error updating squad inbounds: {e}") return False - + + @staticmethod + def _serialize_internal_squad(squad) -> Dict[str, Any]: + inbounds = [ + asdict(inbound) if is_dataclass(inbound) else inbound + for inbound in getattr(squad, "inbounds", []) or [] + ] + return { + 'uuid': getattr(squad, 'uuid', ''), + 'name': getattr(squad, 'name', ''), + 'members_count': getattr(squad, 'members_count', 0), + 'inbounds_count': getattr(squad, 'inbounds_count', 0), + 'inbounds': inbounds, + } + async def get_all_squads(self) -> List[Dict[str, Any]]: - + 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': inbounds, - }) - + result.append(self._serialize_internal_squad(squad)) + logger.info(f"✅ Получено {len(result)} сквадов из Remnawave") return result - + except Exception as e: logger.error(f"Ошибка получения сквадов из Remnawave: {e}") return [] + + async def get_internal_squad(self, uuid: str) -> Optional[Dict[str, Any]]: + try: + async with self.get_api_client() as api: + squad = await api.get_internal_squad_by_uuid(uuid) + if not squad: + return None + return self._serialize_internal_squad(squad) + except Exception as error: + logger.error("Ошибка получения internal squad %s: %s", uuid, error) + return None + + async def get_internal_squad_accessible_nodes(self, uuid: str) -> List[Dict[str, Any]]: + try: + async with self.get_api_client() as api: + nodes = await api.get_internal_squad_accessible_nodes(uuid) + return [ + asdict(node) if is_dataclass(node) else node + for node in nodes or [] + ] + except Exception as error: + logger.error("Ошибка получения нод internal squad %s: %s", uuid, error) + return [] async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]: try: @@ -1704,6 +1731,12 @@ class RemnaWaveService: telegram_id=user.telegram_id, ) + internal_squads = ( + list(user.active_internal_squads or []) + if user.active_internal_squads is not None + else list(subscription.connected_squads or []) + ) + create_kwargs = dict( username=username, expire_at=expire_at, @@ -1716,7 +1749,7 @@ class RemnaWaveService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=internal_squads, ) if hwid_limit is not None: @@ -1730,7 +1763,7 @@ class RemnaWaveService: traffic_limit_bytes=create_kwargs['traffic_limit_bytes'], traffic_limit_strategy=TrafficLimitStrategy.MONTH, description=create_kwargs['description'], - active_internal_squads=subscription.connected_squads, + active_internal_squads=internal_squads, ) if hwid_limit is not None: diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index f7a29e2f..6e663c38 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -139,6 +139,63 @@ class SubscriptionService: return settings.get_paid_subscription_user_tag() + @staticmethod + def _normalize_internal_squads(value: Optional[Iterable[str]]) -> Optional[list[str]]: + if value is None: + return None + + try: + items = [str(item).strip() for item in value if str(item).strip()] + except TypeError: + return None + + seen = set() + normalized: list[str] = [] + for item in items: + lowered = item.lower() + if lowered in seen: + continue + seen.add(lowered) + normalized.append(item) + return normalized + + @staticmethod + def _select_internal_squads(user: User, subscription: Subscription) -> Optional[list[str]]: + if user.active_internal_squads is not None: + return SubscriptionService._normalize_internal_squads(user.active_internal_squads) + return SubscriptionService._normalize_internal_squads(subscription.connected_squads) + + async def _resolve_internal_squad_uuids( + self, + api: RemnaWaveAPI, + squads: Optional[Iterable[str]], + ) -> Optional[list[str]]: + normalized = self._normalize_internal_squads(squads) + if normalized is None: + return None + if not normalized: + return [] + + try: + available = await api.get_internal_squads() + uuid_lookup = {squad.uuid.lower(): squad.uuid for squad in available} + name_lookup = {squad.name.lower(): squad.uuid for squad in available} + except Exception as error: + logger.warning("Не удалось получить список internal squads: %s", error) + return normalized + + resolved: list[str] = [] + for item in normalized: + lowered = item.lower() + if lowered in uuid_lookup: + resolved.append(uuid_lookup[lowered]) + continue + if lowered in name_lookup: + resolved.append(name_lookup[lowered]) + continue + logger.warning("Не удалось сопоставить internal squad '%s' с панелью", item) + return resolved + @property def is_configured(self) -> bool: return self._config_error is None @@ -175,16 +232,24 @@ class SubscriptionService: if not user: logger.error(f"Пользователь {subscription.user_id} не найден") return None - + validation_success = await self.validate_and_clean_subscription(db, subscription, user) if not validation_success: logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}") return None user_tag = self._resolve_user_tag(subscription) + requested_internal_squads = self._select_internal_squads(user, subscription) async with self.get_api_client() as api: hwid_limit = resolve_hwid_device_limit_for_payload(subscription) + resolved_internal_squads = await self._resolve_internal_squad_uuids( + api, + requested_internal_squads, + ) + internal_squads_payload = ( + resolved_internal_squads if resolved_internal_squads is not None else [] + ) existing_users = await api.get_user_by_telegram_id(user.telegram_id) if existing_users: logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}") @@ -207,7 +272,7 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=internal_squads_payload, ) if user_tag is not None: @@ -245,7 +310,7 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=internal_squads_payload, ) if user_tag is not None: @@ -313,9 +378,17 @@ class SubscriptionService: logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'") user_tag = self._resolve_user_tag(subscription) + requested_internal_squads = self._select_internal_squads(user, subscription) async with self.get_api_client() as api: hwid_limit = resolve_hwid_device_limit_for_payload(subscription) + resolved_internal_squads = await self._resolve_internal_squad_uuids( + api, + requested_internal_squads, + ) + internal_squads_payload = ( + resolved_internal_squads if resolved_internal_squads is not None else [] + ) update_kwargs = dict( uuid=user.remnawave_uuid, @@ -328,7 +401,7 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=internal_squads_payload, ) if user_tag is not None: diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 7b78034a..6ad92f74 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -654,6 +654,15 @@ class BotConfigurationService: "warning": "Неверный формат будет проигнорирован при создании пользователя.", "dependencies": "Активация триала и включенная интеграция с RemnaWave", }, + "TRIAL_INTERNAL_SQUADS": { + "description": ( + "Список internal squads, которые нужно назначать пользователям с триальной подпиской." + ), + "format": "Укажите названия сквадов через запятую или с новой строки.", + "example": "Default, Trial Access", + "warning": "При оплате подписки эти сквады будут сброшены.", + "dependencies": "RemnaWave API и активированный триал", + }, "PAID_SUBSCRIPTION_USER_TAG": { "description": ( "Тег, который бот ставит пользователю при покупке платной подписки в панели RemnaWave." diff --git a/app/webapi/routes/remnawave.py b/app/webapi/routes/remnawave.py index b85193d6..4b010edf 100644 --- a/app/webapi/routes/remnawave.py +++ b/app/webapi/routes/remnawave.py @@ -14,6 +14,8 @@ from app.database.crud.server_squad import ( from ..dependencies import get_db_session, require_api_token from ..schemas.remnawave import ( RemnaWaveConnectionStatus, + RemnaWaveAccessibleNode, + RemnaWaveAccessibleNodeListResponse, RemnaWaveGenericSyncResponse, RemnaWaveInboundsResponse, RemnaWaveNode, @@ -150,8 +152,50 @@ async def get_system_statistics( if not stats or "system" not in stats: raise HTTPException(status.HTTP_502_BAD_GATEWAY, "Не удалось получить статистику RemnaWave") - stats["last_updated"] = _parse_last_updated(stats.get("last_updated")) - return RemnaWaveSystemStatsResponse(**stats) + stats["last_updated"] = _parse_last_updated(stats.get("last_updated")) + return RemnaWaveSystemStatsResponse(**stats) + + +@router.get("/internal-squads", response_model=RemnaWaveSquadListResponse) +async def list_internal_squads( + _: Any = Security(require_api_token), +) -> RemnaWaveSquadListResponse: + service = _get_service() + _ensure_service_configured(service) + + squads = await service.get_all_squads() + items = [RemnaWaveSquad(**squad) for squad in squads] + return RemnaWaveSquadListResponse(items=items, total=len(items)) + + +@router.get("/internal-squads/{squad_uuid}", response_model=RemnaWaveSquad) +async def get_internal_squad( + squad_uuid: str, + _: Any = Security(require_api_token), +) -> RemnaWaveSquad: + service = _get_service() + _ensure_service_configured(service) + + squad = await service.get_internal_squad(squad_uuid) + if not squad: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Squad not found") + return RemnaWaveSquad(**squad) + + +@router.get( + "/internal-squads/{squad_uuid}/nodes", + response_model=RemnaWaveAccessibleNodeListResponse, +) +async def get_internal_squad_nodes( + squad_uuid: str, + _: Any = Security(require_api_token), +) -> RemnaWaveAccessibleNodeListResponse: + service = _get_service() + _ensure_service_configured(service) + + nodes = await service.get_internal_squad_accessible_nodes(squad_uuid) + items = [RemnaWaveAccessibleNode(**node) for node in nodes] + return RemnaWaveAccessibleNodeListResponse(items=items, total=len(items)) @router.get("/nodes", response_model=RemnaWaveNodeListResponse) diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py index 7e6b444f..8d9154be 100644 --- a/app/webapi/routes/users.py +++ b/app/webapi/routes/users.py @@ -88,6 +88,7 @@ def _serialize_user(user: User) -> UserResponse: created_at=user.created_at, updated_at=user.updated_at, last_activity=user.last_activity, + active_internal_squads=list(user.active_internal_squads or []), promo_group=_serialize_promo_group(promo_group), subscription=_serialize_subscription(subscription), ) @@ -205,6 +206,7 @@ async def create_user_endpoint( last_name=payload.last_name, language=payload.language, referred_by_id=payload.referred_by_id, + active_internal_squads=payload.active_internal_squads, ) if payload.promo_group_id and payload.promo_group_id != user.promo_group_id: @@ -263,6 +265,9 @@ async def update_user_endpoint( raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found") updates["promo_group_id"] = promo_group.id + if payload.active_internal_squads is not None: + updates["active_internal_squads"] = payload.active_internal_squads + if payload.referral_code is not None and payload.referral_code != found_user.referral_code: existing_code_owner = await get_user_by_referral_code(db, payload.referral_code) if existing_code_owner and existing_code_owner.id != found_user.id: diff --git a/app/webapi/schemas/remnawave.py b/app/webapi/schemas/remnawave.py index d640f5bd..cc00f3cb 100644 --- a/app/webapi/schemas/remnawave.py +++ b/app/webapi/schemas/remnawave.py @@ -137,6 +137,20 @@ class RemnaWaveSquadListResponse(BaseModel): total: int +class RemnaWaveAccessibleNode(BaseModel): + uuid: str + node_name: str + country_code: str + config_profile_uuid: str + config_profile_name: str + active_inbounds: List[str] = Field(default_factory=list) + + +class RemnaWaveAccessibleNodeListResponse(BaseModel): + items: List[RemnaWaveAccessibleNode] + total: int + + class RemnaWaveSquadCreateRequest(BaseModel): name: str inbound_uuids: List[str] = Field(default_factory=list) diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py index fbf910fc..3073ffa4 100644 --- a/app/webapi/schemas/users.py +++ b/app/webapi/schemas/users.py @@ -49,6 +49,7 @@ class UserResponse(BaseModel): created_at: datetime updated_at: datetime last_activity: Optional[datetime] = None + active_internal_squads: List[str] = Field(default_factory=list) promo_group: Optional[PromoGroupSummary] = None subscription: Optional[SubscriptionSummary] = None @@ -68,6 +69,7 @@ class UserCreateRequest(BaseModel): language: str = "ru" referred_by_id: Optional[int] = None promo_group_id: Optional[int] = None + active_internal_squads: Optional[List[str]] = Field(default=None) class UserUpdateRequest(BaseModel): @@ -80,6 +82,7 @@ class UserUpdateRequest(BaseModel): referral_code: Optional[str] = None has_had_paid_subscription: Optional[bool] = None has_made_first_topup: Optional[bool] = None + active_internal_squads: Optional[List[str]] = Field(default=None) class BalanceUpdateRequest(BaseModel): From 0892f494d9dd919f4199e776f36c05de81ee8e26 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 04:17:05 +0300 Subject: [PATCH 17/19] Revert "Add internal squad management for users and trials" --- app/config.py | 23 ------- app/database/crud/subscription.py | 36 +---------- app/database/crud/user.py | 32 +--------- app/database/models.py | 1 - app/database/universal_migration.py | 42 ------------- app/handlers/admin/users.py | 12 +--- app/services/monitoring_service.py | 6 +- app/services/remnawave_service.py | 67 ++++++-------------- app/services/subscription_service.py | 81 ++----------------------- app/services/system_settings_service.py | 9 --- app/webapi/routes/remnawave.py | 48 +-------------- app/webapi/routes/users.py | 5 -- app/webapi/schemas/remnawave.py | 14 ----- app/webapi/schemas/users.py | 3 - 14 files changed, 30 insertions(+), 349 deletions(-) diff --git a/app/config.py b/app/config.py index 196f9817..1bf109b2 100644 --- a/app/config.py +++ b/app/config.py @@ -91,7 +91,6 @@ class Settings(BaseSettings): TRIAL_PAYMENT_ENABLED: bool = False TRIAL_ACTIVATION_PRICE: int = 0 TRIAL_USER_TAG: Optional[str] = None - TRIAL_INTERNAL_SQUADS: Optional[str] = None DEFAULT_TRAFFIC_LIMIT_GB: int = 100 DEFAULT_DEVICE_LIMIT: int = 1 DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH" @@ -817,28 +816,6 @@ class Settings(BaseSettings): def get_trial_user_tag(self) -> Optional[str]: return self._normalize_user_tag(self.TRIAL_USER_TAG, "TRIAL_USER_TAG") - def get_trial_internal_squads(self) -> list[str]: - raw_value = self.TRIAL_INTERNAL_SQUADS - if raw_value is None: - return [] - - if isinstance(raw_value, str): - items = [item.strip() for item in re.split(r"[,\n]", raw_value) if item.strip()] - elif isinstance(raw_value, (list, tuple, set)): - items = [str(item).strip() for item in raw_value if str(item).strip()] - else: - return [] - - seen = set() - unique_items: list[str] = [] - for item in items: - lowered = item.lower() - if lowered in seen: - continue - seen.add(lowered) - unique_items.append(item) - return unique_items - def get_paid_subscription_user_tag(self) -> Optional[str]: return self._normalize_user_tag( self.PAID_SUBSCRIPTION_USER_TAG, diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 4693f478..0ba9f72e 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -72,20 +72,6 @@ async def create_trial_subscription( end_date = datetime.utcnow() + timedelta(days=duration_days) - trial_internal_squads = settings.get_trial_internal_squads() - trial_user: Optional[User] = None - if trial_internal_squads: - try: - trial_user = await db.get(User, user_id) - if trial_user is not None: - trial_user.active_internal_squads = trial_internal_squads - except Exception as error: - logger.warning( - "Не удалось применить internal squads для триала пользователя %s: %s", - user_id, - error, - ) - subscription = Subscription( user_id=user_id, status=SubscriptionStatus.ACTIVE.value, @@ -145,30 +131,10 @@ async def create_paid_subscription( ) -> Subscription: end_date = datetime.utcnow() + timedelta(days=duration_days) - + if device_limit is None: device_limit = settings.DEFAULT_DEVICE_LIMIT - trial_internal_squads = settings.get_trial_internal_squads() - if trial_internal_squads: - try: - paid_user = await db.get(User, user_id) - if paid_user and paid_user.active_internal_squads: - current_set = { - str(item).strip().lower() - for item in paid_user.active_internal_squads - if str(item).strip() - } - trial_set = {item.lower() for item in trial_internal_squads} - if current_set == trial_set: - paid_user.active_internal_squads = [] - except Exception as error: - logger.warning( - "Не удалось сбросить trial internal squads при покупке для пользователя %s: %s", - user_id, - error, - ) - subscription = Subscription( user_id=user_id, status=SubscriptionStatus.ACTIVE.value, diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 88ef9798..2f944df2 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -2,7 +2,7 @@ import logging import secrets import string from datetime import datetime, timedelta -from typing import Optional, List, Dict, Iterable +from typing import Optional, List, Dict from sqlalchemy import select, and_, or_, func, case, nullslast, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload, joinedload @@ -34,26 +34,6 @@ def generate_referral_code() -> str: return f"ref{code_suffix}" -def _normalize_internal_squads(value: Optional[Iterable[str]]) -> Optional[list[str]]: - if value is None: - return None - - try: - items = [str(item).strip() for item in value if str(item).strip()] - except TypeError: - return None - - seen = set() - normalized: list[str] = [] - for item in items: - lowered = item.lower() - if lowered in seen: - continue - seen.add(lowered) - normalized.append(item) - return normalized - - async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]: result = await db.execute( select(User) @@ -191,8 +171,7 @@ async def create_user_no_commit( last_name: str = None, language: str = "ru", referred_by_id: int = None, - referral_code: str = None, - active_internal_squads: Optional[Iterable[str]] = None, + referral_code: str = None ) -> User: """ Создает пользователя без немедленного коммита для пакетной обработки @@ -218,7 +197,6 @@ async def create_user_no_commit( has_had_paid_subscription=False, has_made_first_topup=False, promo_group_id=promo_group_id, - active_internal_squads=_normalize_internal_squads(active_internal_squads), ) db.add(user) @@ -244,8 +222,7 @@ async def create_user( last_name: str = None, language: str = "ru", referred_by_id: int = None, - referral_code: str = None, - active_internal_squads: Optional[Iterable[str]] = None, + referral_code: str = None ) -> User: if not referral_code: @@ -271,7 +248,6 @@ async def create_user( has_had_paid_subscription=False, has_made_first_topup=False, promo_group_id=promo_group_id, - active_internal_squads=_normalize_internal_squads(active_internal_squads), ) db.add(user) @@ -319,8 +295,6 @@ async def update_user( for field, value in kwargs.items(): if field in ("first_name", "last_name"): value = sanitize_telegram_name(value) - if field == "active_internal_squads": - value = _normalize_internal_squads(value) if hasattr(user, field): setattr(user, field, value) diff --git a/app/database/models.py b/app/database/models.py index d3164d86..f8e9f719 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -600,7 +600,6 @@ class User(Base): promo_offer_discount_source = Column(String(100), nullable=True) promo_offer_discount_expires_at = Column(DateTime, nullable=True) last_remnawave_sync = Column(DateTime, nullable=True) - active_internal_squads = Column(JSON, nullable=True) trojan_password = Column(String(255), nullable=True) vless_uuid = Column(String(255), nullable=True) ss_password = Column(String(255), nullable=True) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 1d9d4cb4..6bf30aff 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1489,40 +1489,6 @@ async def ensure_user_promo_offer_discount_columns(): return False -async def ensure_user_internal_squads_column() -> bool: - try: - column_exists = await check_column_exists('users', 'active_internal_squads') - if column_exists: - return True - - async with engine.begin() as conn: - db_type = await get_database_type() - - if db_type == 'sqlite': - column_def = 'JSON NULL' - elif db_type == 'postgresql': - column_def = 'JSONB NULL' - elif db_type == 'mysql': - column_def = 'JSON NULL' - else: - raise ValueError(f"Unsupported database type: {db_type}") - - await conn.execute( - text( - f"ALTER TABLE users ADD COLUMN active_internal_squads {column_def}" - ) - ) - - logger.info("✅ Добавлена колонка active_internal_squads в таблицу users") - return True - except Exception as error: - logger.error( - "Ошибка добавления колонки active_internal_squads в users: %s", - error, - ) - return False - - async def ensure_promo_offer_template_active_duration_column() -> bool: try: column_exists = await check_column_exists('promo_offer_templates', 'active_discount_hours') @@ -4001,12 +3967,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Не удалось обновить пользовательские промо-скидки") - internal_squads_ready = await ensure_user_internal_squads_column() - if internal_squads_ready: - logger.info("✅ Колонка active_internal_squads для users готова") - else: - logger.warning("⚠️ Не удалось обновить колонку active_internal_squads для users") - effect_types_updated = await migrate_discount_offer_effect_types() if effect_types_updated: logger.info("✅ Типы эффектов промо-предложений обновлены") @@ -4307,7 +4267,6 @@ async def check_migration_status(): "promo_offer_templates_active_discount_column": False, "promo_offer_logs_table": False, "subscription_temporary_access_table": False, - "users_active_internal_squads_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -4344,7 +4303,6 @@ async def check_migration_status(): status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at') status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') - status["users_active_internal_squads_column"] = await check_column_exists('users', 'active_internal_squads') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 9b0c4657..d25499e6 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -4606,11 +4606,7 @@ async def admin_buy_subscription_execute( username=target_user.username, telegram_id=target_user.telegram_id ), - active_internal_squads=( - list(target_user.active_internal_squads or []) - if target_user.active_internal_squads is not None - else list(subscription.connected_squads or []) - ), + active_internal_squads=subscription.connected_squads, ) if hwid_limit is not None: @@ -4636,11 +4632,7 @@ async def admin_buy_subscription_execute( username=target_user.username, telegram_id=target_user.telegram_id ), - active_internal_squads=( - list(target_user.active_internal_squads or []) - if target_user.active_internal_squads is not None - else list(subscription.connected_squads or []) - ), + active_internal_squads=subscription.connected_squads, ) if hwid_limit is not None: diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 864bb2be..9a0f6f95 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -312,11 +312,7 @@ class MonitoringService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=( - list(user.active_internal_squads or []) - if user.active_internal_squads is not None - else list(subscription.connected_squads or []) - ), + active_internal_squads=subscription.connected_squads, ) if hwid_limit is not None: diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 468645d3..001b7b54 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -846,60 +846,33 @@ class RemnaWaveService: except Exception as e: logger.error(f"Error updating squad inbounds: {e}") return False - - @staticmethod - def _serialize_internal_squad(squad) -> Dict[str, Any]: - inbounds = [ - asdict(inbound) if is_dataclass(inbound) else inbound - for inbound in getattr(squad, "inbounds", []) or [] - ] - return { - 'uuid': getattr(squad, 'uuid', ''), - 'name': getattr(squad, 'name', ''), - 'members_count': getattr(squad, 'members_count', 0), - 'inbounds_count': getattr(squad, 'inbounds_count', 0), - 'inbounds': inbounds, - } - + async def get_all_squads(self) -> List[Dict[str, Any]]: - + try: async with self.get_api_client() as api: squads = await api.get_internal_squads() result = [] for squad in squads: - result.append(self._serialize_internal_squad(squad)) - + 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': inbounds, + }) + logger.info(f"✅ Получено {len(result)} сквадов из Remnawave") return result - + except Exception as e: logger.error(f"Ошибка получения сквадов из Remnawave: {e}") return [] - - async def get_internal_squad(self, uuid: str) -> Optional[Dict[str, Any]]: - try: - async with self.get_api_client() as api: - squad = await api.get_internal_squad_by_uuid(uuid) - if not squad: - return None - return self._serialize_internal_squad(squad) - except Exception as error: - logger.error("Ошибка получения internal squad %s: %s", uuid, error) - return None - - async def get_internal_squad_accessible_nodes(self, uuid: str) -> List[Dict[str, Any]]: - try: - async with self.get_api_client() as api: - nodes = await api.get_internal_squad_accessible_nodes(uuid) - return [ - asdict(node) if is_dataclass(node) else node - for node in nodes or [] - ] - except Exception as error: - logger.error("Ошибка получения нод internal squad %s: %s", uuid, error) - return [] async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]: try: @@ -1731,12 +1704,6 @@ class RemnaWaveService: telegram_id=user.telegram_id, ) - internal_squads = ( - list(user.active_internal_squads or []) - if user.active_internal_squads is not None - else list(subscription.connected_squads or []) - ) - create_kwargs = dict( username=username, expire_at=expire_at, @@ -1749,7 +1716,7 @@ class RemnaWaveService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=internal_squads, + active_internal_squads=subscription.connected_squads, ) if hwid_limit is not None: @@ -1763,7 +1730,7 @@ class RemnaWaveService: traffic_limit_bytes=create_kwargs['traffic_limit_bytes'], traffic_limit_strategy=TrafficLimitStrategy.MONTH, description=create_kwargs['description'], - active_internal_squads=internal_squads, + active_internal_squads=subscription.connected_squads, ) if hwid_limit is not None: diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 6e663c38..f7a29e2f 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -139,63 +139,6 @@ class SubscriptionService: return settings.get_paid_subscription_user_tag() - @staticmethod - def _normalize_internal_squads(value: Optional[Iterable[str]]) -> Optional[list[str]]: - if value is None: - return None - - try: - items = [str(item).strip() for item in value if str(item).strip()] - except TypeError: - return None - - seen = set() - normalized: list[str] = [] - for item in items: - lowered = item.lower() - if lowered in seen: - continue - seen.add(lowered) - normalized.append(item) - return normalized - - @staticmethod - def _select_internal_squads(user: User, subscription: Subscription) -> Optional[list[str]]: - if user.active_internal_squads is not None: - return SubscriptionService._normalize_internal_squads(user.active_internal_squads) - return SubscriptionService._normalize_internal_squads(subscription.connected_squads) - - async def _resolve_internal_squad_uuids( - self, - api: RemnaWaveAPI, - squads: Optional[Iterable[str]], - ) -> Optional[list[str]]: - normalized = self._normalize_internal_squads(squads) - if normalized is None: - return None - if not normalized: - return [] - - try: - available = await api.get_internal_squads() - uuid_lookup = {squad.uuid.lower(): squad.uuid for squad in available} - name_lookup = {squad.name.lower(): squad.uuid for squad in available} - except Exception as error: - logger.warning("Не удалось получить список internal squads: %s", error) - return normalized - - resolved: list[str] = [] - for item in normalized: - lowered = item.lower() - if lowered in uuid_lookup: - resolved.append(uuid_lookup[lowered]) - continue - if lowered in name_lookup: - resolved.append(name_lookup[lowered]) - continue - logger.warning("Не удалось сопоставить internal squad '%s' с панелью", item) - return resolved - @property def is_configured(self) -> bool: return self._config_error is None @@ -232,24 +175,16 @@ class SubscriptionService: if not user: logger.error(f"Пользователь {subscription.user_id} не найден") return None - + validation_success = await self.validate_and_clean_subscription(db, subscription, user) if not validation_success: logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}") return None user_tag = self._resolve_user_tag(subscription) - requested_internal_squads = self._select_internal_squads(user, subscription) async with self.get_api_client() as api: hwid_limit = resolve_hwid_device_limit_for_payload(subscription) - resolved_internal_squads = await self._resolve_internal_squad_uuids( - api, - requested_internal_squads, - ) - internal_squads_payload = ( - resolved_internal_squads if resolved_internal_squads is not None else [] - ) existing_users = await api.get_user_by_telegram_id(user.telegram_id) if existing_users: logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}") @@ -272,7 +207,7 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=internal_squads_payload, + active_internal_squads=subscription.connected_squads, ) if user_tag is not None: @@ -310,7 +245,7 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=internal_squads_payload, + active_internal_squads=subscription.connected_squads, ) if user_tag is not None: @@ -378,17 +313,9 @@ class SubscriptionService: logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'") user_tag = self._resolve_user_tag(subscription) - requested_internal_squads = self._select_internal_squads(user, subscription) async with self.get_api_client() as api: hwid_limit = resolve_hwid_device_limit_for_payload(subscription) - resolved_internal_squads = await self._resolve_internal_squad_uuids( - api, - requested_internal_squads, - ) - internal_squads_payload = ( - resolved_internal_squads if resolved_internal_squads is not None else [] - ) update_kwargs = dict( uuid=user.remnawave_uuid, @@ -401,7 +328,7 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=internal_squads_payload, + active_internal_squads=subscription.connected_squads, ) if user_tag is not None: diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 6ad92f74..7b78034a 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -654,15 +654,6 @@ class BotConfigurationService: "warning": "Неверный формат будет проигнорирован при создании пользователя.", "dependencies": "Активация триала и включенная интеграция с RemnaWave", }, - "TRIAL_INTERNAL_SQUADS": { - "description": ( - "Список internal squads, которые нужно назначать пользователям с триальной подпиской." - ), - "format": "Укажите названия сквадов через запятую или с новой строки.", - "example": "Default, Trial Access", - "warning": "При оплате подписки эти сквады будут сброшены.", - "dependencies": "RemnaWave API и активированный триал", - }, "PAID_SUBSCRIPTION_USER_TAG": { "description": ( "Тег, который бот ставит пользователю при покупке платной подписки в панели RemnaWave." diff --git a/app/webapi/routes/remnawave.py b/app/webapi/routes/remnawave.py index 4b010edf..b85193d6 100644 --- a/app/webapi/routes/remnawave.py +++ b/app/webapi/routes/remnawave.py @@ -14,8 +14,6 @@ from app.database.crud.server_squad import ( from ..dependencies import get_db_session, require_api_token from ..schemas.remnawave import ( RemnaWaveConnectionStatus, - RemnaWaveAccessibleNode, - RemnaWaveAccessibleNodeListResponse, RemnaWaveGenericSyncResponse, RemnaWaveInboundsResponse, RemnaWaveNode, @@ -152,50 +150,8 @@ async def get_system_statistics( if not stats or "system" not in stats: raise HTTPException(status.HTTP_502_BAD_GATEWAY, "Не удалось получить статистику RemnaWave") - stats["last_updated"] = _parse_last_updated(stats.get("last_updated")) - return RemnaWaveSystemStatsResponse(**stats) - - -@router.get("/internal-squads", response_model=RemnaWaveSquadListResponse) -async def list_internal_squads( - _: Any = Security(require_api_token), -) -> RemnaWaveSquadListResponse: - service = _get_service() - _ensure_service_configured(service) - - squads = await service.get_all_squads() - items = [RemnaWaveSquad(**squad) for squad in squads] - return RemnaWaveSquadListResponse(items=items, total=len(items)) - - -@router.get("/internal-squads/{squad_uuid}", response_model=RemnaWaveSquad) -async def get_internal_squad( - squad_uuid: str, - _: Any = Security(require_api_token), -) -> RemnaWaveSquad: - service = _get_service() - _ensure_service_configured(service) - - squad = await service.get_internal_squad(squad_uuid) - if not squad: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Squad not found") - return RemnaWaveSquad(**squad) - - -@router.get( - "/internal-squads/{squad_uuid}/nodes", - response_model=RemnaWaveAccessibleNodeListResponse, -) -async def get_internal_squad_nodes( - squad_uuid: str, - _: Any = Security(require_api_token), -) -> RemnaWaveAccessibleNodeListResponse: - service = _get_service() - _ensure_service_configured(service) - - nodes = await service.get_internal_squad_accessible_nodes(squad_uuid) - items = [RemnaWaveAccessibleNode(**node) for node in nodes] - return RemnaWaveAccessibleNodeListResponse(items=items, total=len(items)) + stats["last_updated"] = _parse_last_updated(stats.get("last_updated")) + return RemnaWaveSystemStatsResponse(**stats) @router.get("/nodes", response_model=RemnaWaveNodeListResponse) diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py index 8d9154be..7e6b444f 100644 --- a/app/webapi/routes/users.py +++ b/app/webapi/routes/users.py @@ -88,7 +88,6 @@ def _serialize_user(user: User) -> UserResponse: created_at=user.created_at, updated_at=user.updated_at, last_activity=user.last_activity, - active_internal_squads=list(user.active_internal_squads or []), promo_group=_serialize_promo_group(promo_group), subscription=_serialize_subscription(subscription), ) @@ -206,7 +205,6 @@ async def create_user_endpoint( last_name=payload.last_name, language=payload.language, referred_by_id=payload.referred_by_id, - active_internal_squads=payload.active_internal_squads, ) if payload.promo_group_id and payload.promo_group_id != user.promo_group_id: @@ -265,9 +263,6 @@ async def update_user_endpoint( raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found") updates["promo_group_id"] = promo_group.id - if payload.active_internal_squads is not None: - updates["active_internal_squads"] = payload.active_internal_squads - if payload.referral_code is not None and payload.referral_code != found_user.referral_code: existing_code_owner = await get_user_by_referral_code(db, payload.referral_code) if existing_code_owner and existing_code_owner.id != found_user.id: diff --git a/app/webapi/schemas/remnawave.py b/app/webapi/schemas/remnawave.py index cc00f3cb..d640f5bd 100644 --- a/app/webapi/schemas/remnawave.py +++ b/app/webapi/schemas/remnawave.py @@ -137,20 +137,6 @@ class RemnaWaveSquadListResponse(BaseModel): total: int -class RemnaWaveAccessibleNode(BaseModel): - uuid: str - node_name: str - country_code: str - config_profile_uuid: str - config_profile_name: str - active_inbounds: List[str] = Field(default_factory=list) - - -class RemnaWaveAccessibleNodeListResponse(BaseModel): - items: List[RemnaWaveAccessibleNode] - total: int - - class RemnaWaveSquadCreateRequest(BaseModel): name: str inbound_uuids: List[str] = Field(default_factory=list) diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py index 3073ffa4..fbf910fc 100644 --- a/app/webapi/schemas/users.py +++ b/app/webapi/schemas/users.py @@ -49,7 +49,6 @@ class UserResponse(BaseModel): created_at: datetime updated_at: datetime last_activity: Optional[datetime] = None - active_internal_squads: List[str] = Field(default_factory=list) promo_group: Optional[PromoGroupSummary] = None subscription: Optional[SubscriptionSummary] = None @@ -69,7 +68,6 @@ class UserCreateRequest(BaseModel): language: str = "ru" referred_by_id: Optional[int] = None promo_group_id: Optional[int] = None - active_internal_squads: Optional[List[str]] = Field(default=None) class UserUpdateRequest(BaseModel): @@ -82,7 +80,6 @@ class UserUpdateRequest(BaseModel): referral_code: Optional[str] = None has_had_paid_subscription: Optional[bool] = None has_made_first_topup: Optional[bool] = None - active_internal_squads: Optional[List[str]] = Field(default=None) class BalanceUpdateRequest(BaseModel): From 799243a988d2997125fa0aa4729571af8b9cd279 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 04:20:21 +0300 Subject: [PATCH 18/19] Support user-specific internal squads --- app/database/crud/user.py | 8 ++- app/database/models.py | 1 + app/database/universal_migration.py | 37 +++++++++++ app/handlers/admin/users.py | 20 +++--- app/services/monitoring_service.py | 5 +- app/services/remnawave_service.py | 95 ++++++++++++++++++++++------ app/services/subscription_service.py | 13 +++- app/utils/internal_squads.py | 21 ++++++ app/webapi/routes/users.py | 14 ++++ app/webapi/schemas/users.py | 9 +++ 10 files changed, 190 insertions(+), 33 deletions(-) create mode 100644 app/utils/internal_squads.py diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 2f944df2..a57104ec 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -171,7 +171,8 @@ async def create_user_no_commit( last_name: str = None, language: str = "ru", referred_by_id: int = None, - referral_code: str = None + referral_code: str = None, + active_internal_squads: Optional[List[str]] = None, ) -> User: """ Создает пользователя без немедленного коммита для пакетной обработки @@ -197,6 +198,7 @@ async def create_user_no_commit( has_had_paid_subscription=False, has_made_first_topup=False, promo_group_id=promo_group_id, + active_internal_squads=active_internal_squads, ) db.add(user) @@ -222,7 +224,8 @@ async def create_user( last_name: str = None, language: str = "ru", referred_by_id: int = None, - referral_code: str = None + referral_code: str = None, + active_internal_squads: Optional[List[str]] = None, ) -> User: if not referral_code: @@ -248,6 +251,7 @@ async def create_user( has_had_paid_subscription=False, has_made_first_topup=False, promo_group_id=promo_group_id, + active_internal_squads=active_internal_squads, ) db.add(user) diff --git a/app/database/models.py b/app/database/models.py index f8e9f719..418ea0c2 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -585,6 +585,7 @@ class User(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) last_activity = Column(DateTime, default=func.now()) remnawave_uuid = Column(String(255), nullable=True, unique=True) + active_internal_squads = Column(JSON, nullable=True) broadcasts = relationship("BroadcastHistory", back_populates="admin") referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id") subscription = relationship("Subscription", back_populates="user", uselist=False) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 6bf30aff..b9ea363c 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -3650,6 +3650,36 @@ async def add_promo_group_priority_column() -> bool: return False +async def add_user_active_internal_squads_column() -> bool: + """Добавляет колонку active_internal_squads в таблицу users.""" + column_exists = await check_column_exists('users', 'active_internal_squads') + if column_exists: + logger.info("Колонка active_internal_squads уже существует в users") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + column_def = 'JSON' + elif db_type == 'postgresql': + column_def = 'JSONB' + else: + column_def = 'JSON' + + await conn.execute( + text(f"ALTER TABLE users ADD COLUMN active_internal_squads {column_def}") + ) + + logger.info("✅ Добавлена колонка active_internal_squads в users") + return True + + except Exception as error: + logger.error(f"Ошибка добавления колонки active_internal_squads: {error}") + return False + + async def create_user_promo_groups_table() -> bool: """Создает таблицу user_promo_groups для связи Many-to-Many между users и promo_groups.""" table_exists = await check_table_exists("user_promo_groups") @@ -3993,6 +4023,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением priority в promo_groups") + logger.info("=== ДОБАВЛЕНИЕ INTERNAL SQUADS ДЛЯ USERS ===") + internal_squads_ready = await add_user_active_internal_squads_column() + if internal_squads_ready: + logger.info("✅ Колонка active_internal_squads в users готова") + else: + logger.warning("⚠️ Проблемы с добавлением active_internal_squads в users") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_PROMO_GROUPS ===") user_promo_groups_ready = await create_user_promo_groups_table() if user_promo_groups_ready: diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index d25499e6..2a302158 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -32,6 +32,7 @@ from app.services.admin_notification_service import AdminNotificationService from app.database.crud.promo_group import get_promo_groups_with_counts from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_time_ago +from app.utils.internal_squads import resolve_user_internal_squads from app.utils.user_utils import get_effective_referral_commission_percent from app.services.remnawave_service import RemnaWaveService from app.external.remnawave_api import TrafficLimitStrategy @@ -3401,7 +3402,7 @@ async def _show_servers_for_user( user = await get_user_by_id(db, user_id) current_squads = [] if user and user.subscription: - current_squads = user.subscription.connected_squads or [] + current_squads = resolve_user_internal_squads(user, user.subscription) all_servers, _ = await get_all_server_squads(db, available_only=False) @@ -3490,18 +3491,19 @@ async def toggle_user_server( if not server: await callback.answer("❌ Сервер не найден", show_alert=True) return - + subscription = user.subscription - current_squads = list(subscription.connected_squads or []) - + current_squads = resolve_user_internal_squads(user, subscription) + if server.squad_uuid in current_squads: current_squads.remove(server.squad_uuid) action_text = "удален" else: current_squads.append(server.squad_uuid) action_text = "добавлен" - + subscription.connected_squads = current_squads + user.active_internal_squads = current_squads subscription.updated_at = datetime.utcnow() await db.commit() await db.refresh(subscription) @@ -4590,8 +4592,10 @@ async def admin_buy_subscription_execute( from app.services.remnawave_service import RemnaWaveService from app.external.remnawave_api import UserStatus, TrafficLimitStrategy remnawave_service = RemnaWaveService() - + hwid_limit = resolve_hwid_device_limit_for_payload(subscription) + active_squads = resolve_user_internal_squads(target_user, subscription) + target_user.active_internal_squads = active_squads if target_user.remnawave_uuid: async with remnawave_service.get_api_client() as api: @@ -4606,7 +4610,7 @@ async def admin_buy_subscription_execute( username=target_user.username, telegram_id=target_user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=active_squads, ) if hwid_limit is not None: @@ -4632,7 +4636,7 @@ async def admin_buy_subscription_execute( username=target_user.username, telegram_id=target_user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=active_squads, ) if hwid_limit is not None: diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 9a0f6f95..ad8f9bb6 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -38,6 +38,7 @@ from app.database.crud.user import ( subtract_user_balance, cleanup_expired_promo_offer_discounts, ) +from app.utils.internal_squads import resolve_user_internal_squads from app.utils.timezone import format_local_datetime from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, @@ -312,7 +313,9 @@ class MonitoringService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=resolve_user_internal_squads( + user, subscription + ), ) if hwid_limit is not None: diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 001b7b54..7a6c7070 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -42,6 +42,7 @@ from app.database.models import ( from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) +from app.utils.internal_squads import resolve_user_internal_squads from app.utils.timezone import get_local_timezone logger = logging.getLogger(__name__) @@ -182,6 +183,70 @@ class RemnaWaveService: self._config_error or "RemnaWave API не настроен" ) + async def resolve_internal_squad_uuids(self, identifiers: List[str]) -> List[str]: + """Конвертирует названия/UUID сквадов в UUID через панель RemnaWave.""" + + if not identifiers: + return [] + + cleaned: List[str] = [] + seen = set() + + for ident in identifiers: + if not ident: + continue + + value = str(ident).strip() + if not value or value in seen: + continue + + seen.add(value) + cleaned.append(value) + + if not cleaned: + return [] + + try: + async with self.get_api_client() as api: + squads = await api.get_internal_squads() + except Exception as error: + logger.error("❌ Не удалось получить список Internal Squads: %s", error) + return [] + + name_to_uuid = {squad.name.lower(): squad.uuid for squad in squads} + uuid_set = {squad.uuid for squad in squads} + + resolved: List[str] = [] + for value in cleaned: + if value in uuid_set: + resolved.append(value) + continue + + mapped = name_to_uuid.get(value.lower()) + if mapped: + resolved.append(mapped) + else: + logger.warning("⚠️ Неизвестный Internal Squad: %s", value) + + return resolved + + async def normalize_active_internal_squads(self, active_squads: List[Any]) -> List[str]: + """Приводит входные данные о сквадах (имя/UUID/словарь) к списку UUID.""" + + identifiers: List[str] = [] + + for squad in active_squads or []: + identifier = None + if isinstance(squad, dict): + identifier = squad.get('uuid') or squad.get('name') + else: + identifier = squad + + if identifier: + identifiers.append(str(identifier)) + + return await self.resolve_internal_squad_uuids(identifiers) + def _ensure_user_remnawave_uuid( self, user: "User", @@ -1017,6 +1082,7 @@ class RemnaWaveService: uuid=subscription.user.remnawave_uuid, active_internal_squads=new_squads, ) + subscription.user.active_internal_squads = new_squads panel_updated += 1 except Exception as error: panel_failed += 1 @@ -1506,14 +1572,9 @@ class RemnaWaveService: traffic_used_gb = used_traffic_bytes / (1024**3) active_squads = panel_user.get('activeInternalSquads', []) - squad_uuids = [] - if isinstance(active_squads, list): - for squad in active_squads: - if isinstance(squad, dict) and 'uuid' in squad: - squad_uuids.append(squad['uuid']) - elif isinstance(squad, str): - squad_uuids.append(squad) - + squad_uuids = await self.normalize_active_internal_squads(active_squads) + user.active_internal_squads = squad_uuids + subscription_data = { 'user_id': user.id, 'status': status.value, @@ -1647,16 +1708,11 @@ class RemnaWaveService: ) if panel_crypto_link and subscription.subscription_crypto_link != panel_crypto_link: subscription.subscription_crypto_link = panel_crypto_link - + active_squads = panel_user.get('activeInternalSquads', []) - squad_uuids = [] - if isinstance(active_squads, list): - for squad in active_squads: - if isinstance(squad, dict) and 'uuid' in squad: - squad_uuids.append(squad['uuid']) - elif isinstance(squad, str): - squad_uuids.append(squad) - + squad_uuids = await self.normalize_active_internal_squads(active_squads) + user.active_internal_squads = squad_uuids + current_squads = set(subscription.connected_squads or []) new_squads = set(squad_uuids) @@ -1694,6 +1750,7 @@ class RemnaWaveService: try: subscription = user.subscription hwid_limit = resolve_hwid_device_limit_for_payload(subscription) + active_squads = resolve_user_internal_squads(user, subscription) expire_at = self._safe_expire_at_for_panel(subscription.end_date) status = UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED @@ -1716,7 +1773,7 @@ class RemnaWaveService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=active_squads, ) if hwid_limit is not None: @@ -1730,7 +1787,7 @@ class RemnaWaveService: traffic_limit_bytes=create_kwargs['traffic_limit_bytes'], traffic_limit_strategy=TrafficLimitStrategy.MONTH, description=create_kwargs['description'], - active_internal_squads=subscription.connected_squads, + active_internal_squads=active_squads, ) if hwid_limit is not None: diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index f7a29e2f..1d34e825 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -11,6 +11,7 @@ from app.external.remnawave_api import ( TrafficLimitStrategy, RemnaWaveAPIError ) from app.database.crud.user import get_user_by_id +from app.utils.internal_squads import resolve_user_internal_squads from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, @@ -207,7 +208,9 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=resolve_user_internal_squads( + user, subscription + ), ) if user_tag is not None: @@ -245,7 +248,9 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=resolve_user_internal_squads( + user, subscription + ), ) if user_tag is not None: @@ -328,7 +333,9 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=subscription.connected_squads, + active_internal_squads=resolve_user_internal_squads( + user, subscription + ), ) if user_tag is not None: diff --git a/app/utils/internal_squads.py b/app/utils/internal_squads.py new file mode 100644 index 00000000..b40cc6fc --- /dev/null +++ b/app/utils/internal_squads.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from app.database.models import Subscription, User + + +def resolve_user_internal_squads( + user: Optional[User], subscription: Optional[Subscription] +) -> List[str]: + """Возвращает список Internal Squads для пользователя. + + Приоритет у персональных сквадов пользователя. Если они не заданы, + используется список сквадов из подписки. + """ + + if user and getattr(user, "active_internal_squads", None) is not None: + return list(user.active_internal_squads or []) + + if subscription: + return list(subscription.connected_squads or []) + + return [] diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py index 7e6b444f..4b72fe41 100644 --- a/app/webapi/routes/users.py +++ b/app/webapi/routes/users.py @@ -17,6 +17,7 @@ from app.database.crud.user import ( update_user, ) from app.database.models import PromoGroup, Subscription, User, UserStatus +from app.services.remnawave_service import RemnaWaveService from ..dependencies import get_db_session, require_api_token from ..schemas.users import ( @@ -30,6 +31,7 @@ from ..schemas.users import ( ) router = APIRouter() +remnawave_service = RemnaWaveService() def _serialize_promo_group(group: Optional[PromoGroup]) -> Optional[PromoGroupSummary]: @@ -90,6 +92,7 @@ def _serialize_user(user: User) -> UserResponse: last_activity=user.last_activity, promo_group=_serialize_promo_group(promo_group), subscription=_serialize_subscription(subscription), + active_internal_squads=list(getattr(user, "active_internal_squads", []) or []), ) @@ -197,6 +200,12 @@ async def create_user_endpoint( if existing: raise HTTPException(status.HTTP_400_BAD_REQUEST, "User with this telegram_id already exists") + active_squads = None + if payload.active_internal_squads is not None: + active_squads = await remnawave_service.resolve_internal_squad_uuids( + payload.active_internal_squads + ) + user = await create_user( db, telegram_id=payload.telegram_id, @@ -205,6 +214,7 @@ async def create_user_endpoint( last_name=payload.last_name, language=payload.language, referred_by_id=payload.referred_by_id, + active_internal_squads=active_squads, ) if payload.promo_group_id and payload.promo_group_id != user.promo_group_id: @@ -249,6 +259,10 @@ async def update_user_endpoint( updates["has_had_paid_subscription"] = payload.has_had_paid_subscription if payload.has_made_first_topup is not None: updates["has_made_first_topup"] = payload.has_made_first_topup + if payload.active_internal_squads is not None: + updates["active_internal_squads"] = await remnawave_service.resolve_internal_squad_uuids( + payload.active_internal_squads + ) if payload.status is not None: try: diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py index fbf910fc..844e50cc 100644 --- a/app/webapi/schemas/users.py +++ b/app/webapi/schemas/users.py @@ -51,6 +51,7 @@ class UserResponse(BaseModel): last_activity: Optional[datetime] = None promo_group: Optional[PromoGroupSummary] = None subscription: Optional[SubscriptionSummary] = None + active_internal_squads: List[str] = Field(default_factory=list) class UserListResponse(BaseModel): @@ -68,6 +69,10 @@ class UserCreateRequest(BaseModel): language: str = "ru" referred_by_id: Optional[int] = None promo_group_id: Optional[int] = None + active_internal_squads: Optional[List[str]] = Field( + default=None, + description="Названия или UUID Internal Squads, которые нужно назначить пользователю" + ) class UserUpdateRequest(BaseModel): @@ -80,6 +85,10 @@ class UserUpdateRequest(BaseModel): referral_code: Optional[str] = None has_had_paid_subscription: Optional[bool] = None has_made_first_topup: Optional[bool] = None + active_internal_squads: Optional[List[str]] = Field( + default=None, + description="Названия или UUID Internal Squads, которые нужно назначить пользователю" + ) class BalanceUpdateRequest(BaseModel): From 136cae68f1e79bc78e53c0b49020780a1591dac1 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 04:23:57 +0300 Subject: [PATCH 19/19] Revert "Support user-specific internal squads" --- app/database/crud/user.py | 8 +-- app/database/models.py | 1 - app/database/universal_migration.py | 37 ----------- app/handlers/admin/users.py | 20 +++--- app/services/monitoring_service.py | 5 +- app/services/remnawave_service.py | 95 ++++++---------------------- app/services/subscription_service.py | 13 +--- app/utils/internal_squads.py | 21 ------ app/webapi/routes/users.py | 14 ---- app/webapi/schemas/users.py | 9 --- 10 files changed, 33 insertions(+), 190 deletions(-) delete mode 100644 app/utils/internal_squads.py diff --git a/app/database/crud/user.py b/app/database/crud/user.py index a57104ec..2f944df2 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -171,8 +171,7 @@ async def create_user_no_commit( last_name: str = None, language: str = "ru", referred_by_id: int = None, - referral_code: str = None, - active_internal_squads: Optional[List[str]] = None, + referral_code: str = None ) -> User: """ Создает пользователя без немедленного коммита для пакетной обработки @@ -198,7 +197,6 @@ async def create_user_no_commit( has_had_paid_subscription=False, has_made_first_topup=False, promo_group_id=promo_group_id, - active_internal_squads=active_internal_squads, ) db.add(user) @@ -224,8 +222,7 @@ async def create_user( last_name: str = None, language: str = "ru", referred_by_id: int = None, - referral_code: str = None, - active_internal_squads: Optional[List[str]] = None, + referral_code: str = None ) -> User: if not referral_code: @@ -251,7 +248,6 @@ async def create_user( has_had_paid_subscription=False, has_made_first_topup=False, promo_group_id=promo_group_id, - active_internal_squads=active_internal_squads, ) db.add(user) diff --git a/app/database/models.py b/app/database/models.py index 418ea0c2..f8e9f719 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -585,7 +585,6 @@ class User(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) last_activity = Column(DateTime, default=func.now()) remnawave_uuid = Column(String(255), nullable=True, unique=True) - active_internal_squads = Column(JSON, nullable=True) broadcasts = relationship("BroadcastHistory", back_populates="admin") referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id") subscription = relationship("Subscription", back_populates="user", uselist=False) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index b9ea363c..6bf30aff 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -3650,36 +3650,6 @@ async def add_promo_group_priority_column() -> bool: return False -async def add_user_active_internal_squads_column() -> bool: - """Добавляет колонку active_internal_squads в таблицу users.""" - column_exists = await check_column_exists('users', 'active_internal_squads') - if column_exists: - logger.info("Колонка active_internal_squads уже существует в users") - return True - - try: - async with engine.begin() as conn: - db_type = await get_database_type() - - if db_type == 'sqlite': - column_def = 'JSON' - elif db_type == 'postgresql': - column_def = 'JSONB' - else: - column_def = 'JSON' - - await conn.execute( - text(f"ALTER TABLE users ADD COLUMN active_internal_squads {column_def}") - ) - - logger.info("✅ Добавлена колонка active_internal_squads в users") - return True - - except Exception as error: - logger.error(f"Ошибка добавления колонки active_internal_squads: {error}") - return False - - async def create_user_promo_groups_table() -> bool: """Создает таблицу user_promo_groups для связи Many-to-Many между users и promo_groups.""" table_exists = await check_table_exists("user_promo_groups") @@ -4023,13 +3993,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с добавлением priority в promo_groups") - logger.info("=== ДОБАВЛЕНИЕ INTERNAL SQUADS ДЛЯ USERS ===") - internal_squads_ready = await add_user_active_internal_squads_column() - if internal_squads_ready: - logger.info("✅ Колонка active_internal_squads в users готова") - else: - logger.warning("⚠️ Проблемы с добавлением active_internal_squads в users") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_PROMO_GROUPS ===") user_promo_groups_ready = await create_user_promo_groups_table() if user_promo_groups_ready: diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 2a302158..d25499e6 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -32,7 +32,6 @@ from app.services.admin_notification_service import AdminNotificationService from app.database.crud.promo_group import get_promo_groups_with_counts from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_time_ago -from app.utils.internal_squads import resolve_user_internal_squads from app.utils.user_utils import get_effective_referral_commission_percent from app.services.remnawave_service import RemnaWaveService from app.external.remnawave_api import TrafficLimitStrategy @@ -3402,7 +3401,7 @@ async def _show_servers_for_user( user = await get_user_by_id(db, user_id) current_squads = [] if user and user.subscription: - current_squads = resolve_user_internal_squads(user, user.subscription) + current_squads = user.subscription.connected_squads or [] all_servers, _ = await get_all_server_squads(db, available_only=False) @@ -3491,19 +3490,18 @@ async def toggle_user_server( if not server: await callback.answer("❌ Сервер не найден", show_alert=True) return - + subscription = user.subscription - current_squads = resolve_user_internal_squads(user, subscription) - + current_squads = list(subscription.connected_squads or []) + if server.squad_uuid in current_squads: current_squads.remove(server.squad_uuid) action_text = "удален" else: current_squads.append(server.squad_uuid) action_text = "добавлен" - + subscription.connected_squads = current_squads - user.active_internal_squads = current_squads subscription.updated_at = datetime.utcnow() await db.commit() await db.refresh(subscription) @@ -4592,10 +4590,8 @@ async def admin_buy_subscription_execute( from app.services.remnawave_service import RemnaWaveService from app.external.remnawave_api import UserStatus, TrafficLimitStrategy remnawave_service = RemnaWaveService() - + hwid_limit = resolve_hwid_device_limit_for_payload(subscription) - active_squads = resolve_user_internal_squads(target_user, subscription) - target_user.active_internal_squads = active_squads if target_user.remnawave_uuid: async with remnawave_service.get_api_client() as api: @@ -4610,7 +4606,7 @@ async def admin_buy_subscription_execute( username=target_user.username, telegram_id=target_user.telegram_id ), - active_internal_squads=active_squads, + active_internal_squads=subscription.connected_squads, ) if hwid_limit is not None: @@ -4636,7 +4632,7 @@ async def admin_buy_subscription_execute( username=target_user.username, telegram_id=target_user.telegram_id ), - active_internal_squads=active_squads, + active_internal_squads=subscription.connected_squads, ) if hwid_limit is not None: diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index ad8f9bb6..9a0f6f95 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -38,7 +38,6 @@ from app.database.crud.user import ( subtract_user_balance, cleanup_expired_promo_offer_discounts, ) -from app.utils.internal_squads import resolve_user_internal_squads from app.utils.timezone import format_local_datetime from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, @@ -313,9 +312,7 @@ class MonitoringService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=resolve_user_internal_squads( - user, subscription - ), + active_internal_squads=subscription.connected_squads, ) if hwid_limit is not None: diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 7a6c7070..001b7b54 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -42,7 +42,6 @@ from app.database.models import ( from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) -from app.utils.internal_squads import resolve_user_internal_squads from app.utils.timezone import get_local_timezone logger = logging.getLogger(__name__) @@ -183,70 +182,6 @@ class RemnaWaveService: self._config_error or "RemnaWave API не настроен" ) - async def resolve_internal_squad_uuids(self, identifiers: List[str]) -> List[str]: - """Конвертирует названия/UUID сквадов в UUID через панель RemnaWave.""" - - if not identifiers: - return [] - - cleaned: List[str] = [] - seen = set() - - for ident in identifiers: - if not ident: - continue - - value = str(ident).strip() - if not value or value in seen: - continue - - seen.add(value) - cleaned.append(value) - - if not cleaned: - return [] - - try: - async with self.get_api_client() as api: - squads = await api.get_internal_squads() - except Exception as error: - logger.error("❌ Не удалось получить список Internal Squads: %s", error) - return [] - - name_to_uuid = {squad.name.lower(): squad.uuid for squad in squads} - uuid_set = {squad.uuid for squad in squads} - - resolved: List[str] = [] - for value in cleaned: - if value in uuid_set: - resolved.append(value) - continue - - mapped = name_to_uuid.get(value.lower()) - if mapped: - resolved.append(mapped) - else: - logger.warning("⚠️ Неизвестный Internal Squad: %s", value) - - return resolved - - async def normalize_active_internal_squads(self, active_squads: List[Any]) -> List[str]: - """Приводит входные данные о сквадах (имя/UUID/словарь) к списку UUID.""" - - identifiers: List[str] = [] - - for squad in active_squads or []: - identifier = None - if isinstance(squad, dict): - identifier = squad.get('uuid') or squad.get('name') - else: - identifier = squad - - if identifier: - identifiers.append(str(identifier)) - - return await self.resolve_internal_squad_uuids(identifiers) - def _ensure_user_remnawave_uuid( self, user: "User", @@ -1082,7 +1017,6 @@ class RemnaWaveService: uuid=subscription.user.remnawave_uuid, active_internal_squads=new_squads, ) - subscription.user.active_internal_squads = new_squads panel_updated += 1 except Exception as error: panel_failed += 1 @@ -1572,9 +1506,14 @@ class RemnaWaveService: traffic_used_gb = used_traffic_bytes / (1024**3) active_squads = panel_user.get('activeInternalSquads', []) - squad_uuids = await self.normalize_active_internal_squads(active_squads) - user.active_internal_squads = squad_uuids - + squad_uuids = [] + if isinstance(active_squads, list): + for squad in active_squads: + if isinstance(squad, dict) and 'uuid' in squad: + squad_uuids.append(squad['uuid']) + elif isinstance(squad, str): + squad_uuids.append(squad) + subscription_data = { 'user_id': user.id, 'status': status.value, @@ -1708,11 +1647,16 @@ class RemnaWaveService: ) if panel_crypto_link and subscription.subscription_crypto_link != panel_crypto_link: subscription.subscription_crypto_link = panel_crypto_link - + active_squads = panel_user.get('activeInternalSquads', []) - squad_uuids = await self.normalize_active_internal_squads(active_squads) - user.active_internal_squads = squad_uuids - + squad_uuids = [] + if isinstance(active_squads, list): + for squad in active_squads: + if isinstance(squad, dict) and 'uuid' in squad: + squad_uuids.append(squad['uuid']) + elif isinstance(squad, str): + squad_uuids.append(squad) + current_squads = set(subscription.connected_squads or []) new_squads = set(squad_uuids) @@ -1750,7 +1694,6 @@ class RemnaWaveService: try: subscription = user.subscription hwid_limit = resolve_hwid_device_limit_for_payload(subscription) - active_squads = resolve_user_internal_squads(user, subscription) expire_at = self._safe_expire_at_for_panel(subscription.end_date) status = UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED @@ -1773,7 +1716,7 @@ class RemnaWaveService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=active_squads, + active_internal_squads=subscription.connected_squads, ) if hwid_limit is not None: @@ -1787,7 +1730,7 @@ class RemnaWaveService: traffic_limit_bytes=create_kwargs['traffic_limit_bytes'], traffic_limit_strategy=TrafficLimitStrategy.MONTH, description=create_kwargs['description'], - active_internal_squads=active_squads, + active_internal_squads=subscription.connected_squads, ) if hwid_limit is not None: diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 1d34e825..f7a29e2f 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -11,7 +11,6 @@ from app.external.remnawave_api import ( TrafficLimitStrategy, RemnaWaveAPIError ) from app.database.crud.user import get_user_by_id -from app.utils.internal_squads import resolve_user_internal_squads from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, @@ -208,9 +207,7 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=resolve_user_internal_squads( - user, subscription - ), + active_internal_squads=subscription.connected_squads, ) if user_tag is not None: @@ -248,9 +245,7 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=resolve_user_internal_squads( - user, subscription - ), + active_internal_squads=subscription.connected_squads, ) if user_tag is not None: @@ -333,9 +328,7 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id ), - active_internal_squads=resolve_user_internal_squads( - user, subscription - ), + active_internal_squads=subscription.connected_squads, ) if user_tag is not None: diff --git a/app/utils/internal_squads.py b/app/utils/internal_squads.py deleted file mode 100644 index b40cc6fc..00000000 --- a/app/utils/internal_squads.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import List, Optional - -from app.database.models import Subscription, User - - -def resolve_user_internal_squads( - user: Optional[User], subscription: Optional[Subscription] -) -> List[str]: - """Возвращает список Internal Squads для пользователя. - - Приоритет у персональных сквадов пользователя. Если они не заданы, - используется список сквадов из подписки. - """ - - if user and getattr(user, "active_internal_squads", None) is not None: - return list(user.active_internal_squads or []) - - if subscription: - return list(subscription.connected_squads or []) - - return [] diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py index 4b72fe41..7e6b444f 100644 --- a/app/webapi/routes/users.py +++ b/app/webapi/routes/users.py @@ -17,7 +17,6 @@ from app.database.crud.user import ( update_user, ) from app.database.models import PromoGroup, Subscription, User, UserStatus -from app.services.remnawave_service import RemnaWaveService from ..dependencies import get_db_session, require_api_token from ..schemas.users import ( @@ -31,7 +30,6 @@ from ..schemas.users import ( ) router = APIRouter() -remnawave_service = RemnaWaveService() def _serialize_promo_group(group: Optional[PromoGroup]) -> Optional[PromoGroupSummary]: @@ -92,7 +90,6 @@ def _serialize_user(user: User) -> UserResponse: last_activity=user.last_activity, promo_group=_serialize_promo_group(promo_group), subscription=_serialize_subscription(subscription), - active_internal_squads=list(getattr(user, "active_internal_squads", []) or []), ) @@ -200,12 +197,6 @@ async def create_user_endpoint( if existing: raise HTTPException(status.HTTP_400_BAD_REQUEST, "User with this telegram_id already exists") - active_squads = None - if payload.active_internal_squads is not None: - active_squads = await remnawave_service.resolve_internal_squad_uuids( - payload.active_internal_squads - ) - user = await create_user( db, telegram_id=payload.telegram_id, @@ -214,7 +205,6 @@ async def create_user_endpoint( last_name=payload.last_name, language=payload.language, referred_by_id=payload.referred_by_id, - active_internal_squads=active_squads, ) if payload.promo_group_id and payload.promo_group_id != user.promo_group_id: @@ -259,10 +249,6 @@ async def update_user_endpoint( updates["has_had_paid_subscription"] = payload.has_had_paid_subscription if payload.has_made_first_topup is not None: updates["has_made_first_topup"] = payload.has_made_first_topup - if payload.active_internal_squads is not None: - updates["active_internal_squads"] = await remnawave_service.resolve_internal_squad_uuids( - payload.active_internal_squads - ) if payload.status is not None: try: diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py index 844e50cc..fbf910fc 100644 --- a/app/webapi/schemas/users.py +++ b/app/webapi/schemas/users.py @@ -51,7 +51,6 @@ class UserResponse(BaseModel): last_activity: Optional[datetime] = None promo_group: Optional[PromoGroupSummary] = None subscription: Optional[SubscriptionSummary] = None - active_internal_squads: List[str] = Field(default_factory=list) class UserListResponse(BaseModel): @@ -69,10 +68,6 @@ class UserCreateRequest(BaseModel): language: str = "ru" referred_by_id: Optional[int] = None promo_group_id: Optional[int] = None - active_internal_squads: Optional[List[str]] = Field( - default=None, - description="Названия или UUID Internal Squads, которые нужно назначить пользователю" - ) class UserUpdateRequest(BaseModel): @@ -85,10 +80,6 @@ class UserUpdateRequest(BaseModel): referral_code: Optional[str] = None has_had_paid_subscription: Optional[bool] = None has_made_first_topup: Optional[bool] = None - active_internal_squads: Optional[List[str]] = Field( - default=None, - description="Названия или UUID Internal Squads, которые нужно назначить пользователю" - ) class BalanceUpdateRequest(BaseModel):