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