mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
1268 lines
51 KiB
Python
1268 lines
51 KiB
Python
import asyncio
|
||
import json
|
||
import ssl
|
||
import base64
|
||
from datetime import datetime, timedelta
|
||
from typing import Dict, List, Optional, Union, Any
|
||
import aiohttp
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from enum import Enum
|
||
from urllib.parse import urlparse, urljoin
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class UserStatus(Enum):
|
||
ACTIVE = "ACTIVE"
|
||
DISABLED = "DISABLED"
|
||
LIMITED = "LIMITED"
|
||
EXPIRED = "EXPIRED"
|
||
|
||
|
||
class TrafficLimitStrategy(Enum):
|
||
NO_RESET = "NO_RESET"
|
||
DAY = "DAY"
|
||
WEEK = "WEEK"
|
||
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
|
||
traffic_limit_bytes: int
|
||
traffic_limit_strategy: TrafficLimitStrategy
|
||
expire_at: datetime
|
||
telegram_id: Optional[int]
|
||
email: Optional[str]
|
||
hwid_device_limit: Optional[int]
|
||
description: Optional[str]
|
||
tag: Optional[str]
|
||
subscription_url: str
|
||
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
|
||
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
|
||
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
|
||
class RemnaWaveInternalSquad:
|
||
uuid: str
|
||
name: str
|
||
members_count: int
|
||
inbounds_count: int
|
||
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
|
||
name: str
|
||
address: str
|
||
country_code: str
|
||
is_connected: bool
|
||
is_disabled: 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
|
||
class SubscriptionInfo:
|
||
is_found: bool
|
||
user: Optional[Dict[str, Any]]
|
||
links: List[str]
|
||
ss_conf_links: Dict[str, str]
|
||
subscription_url: str
|
||
happ: Optional[Dict[str, str]]
|
||
happ_link: Optional[str] = None
|
||
happ_crypto_link: Optional[str] = None
|
||
|
||
|
||
@dataclass
|
||
class SubscriptionPageConfig:
|
||
"""Конфигурация страницы подписки"""
|
||
uuid: str
|
||
name: str
|
||
view_position: int
|
||
config: Optional[Dict[str, Any]] = None
|
||
|
||
|
||
@dataclass
|
||
class RemnaWaveExternalSquad:
|
||
"""Структура External Squad"""
|
||
uuid: str
|
||
name: str
|
||
view_position: int
|
||
members_count: int
|
||
templates: List[Dict[str, str]]
|
||
subscription_settings: Optional[Dict[str, Any]] = None
|
||
host_overrides: Optional[Dict[str, Any]] = None
|
||
response_headers: Optional[Dict[str, str]] = None
|
||
hwid_settings: Optional[Dict[str, Any]] = None
|
||
custom_remarks: Optional[Dict[str, Any]] = None
|
||
subpage_config_uuid: Optional[str] = None
|
||
created_at: Optional[datetime] = None
|
||
updated_at: Optional[datetime] = None
|
||
|
||
|
||
class RemnaWaveAPIError(Exception):
|
||
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
|
||
self.message = message
|
||
self.status_code = status_code
|
||
self.response_data = response_data
|
||
super().__init__(self.message)
|
||
|
||
|
||
class RemnaWaveAPI:
|
||
|
||
def __init__(
|
||
self,
|
||
base_url: str,
|
||
api_key: str,
|
||
secret_key: Optional[str] = None,
|
||
username: Optional[str] = None,
|
||
password: Optional[str] = None,
|
||
caddy_token: Optional[str] = None,
|
||
auth_type: str = "api_key",
|
||
):
|
||
self.base_url = base_url.rstrip('/')
|
||
self.api_key = api_key
|
||
self.secret_key = secret_key
|
||
self.username = username
|
||
self.password = password
|
||
self.caddy_token = caddy_token
|
||
self.auth_type = auth_type.lower() if auth_type else "api_key"
|
||
self.session: Optional[aiohttp.ClientSession] = None
|
||
self.authenticated = False
|
||
|
||
def _detect_connection_type(self) -> str:
|
||
parsed = urlparse(self.base_url)
|
||
|
||
local_hosts = [
|
||
'localhost', '127.0.0.1', 'remnawave',
|
||
'remnawave-backend', 'app', 'api'
|
||
]
|
||
|
||
if parsed.hostname in local_hosts:
|
||
return "local"
|
||
|
||
if parsed.hostname:
|
||
if (parsed.hostname.startswith('192.168.') or
|
||
parsed.hostname.startswith('10.') or
|
||
parsed.hostname.startswith('172.') or
|
||
parsed.hostname.endswith('.local')):
|
||
return "local"
|
||
|
||
return "external"
|
||
|
||
def _prepare_auth_headers(self) -> Dict[str, str]:
|
||
headers = {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
'X-Forwarded-Proto': 'https',
|
||
'X-Forwarded-For': '127.0.0.1',
|
||
'X-Real-IP': '127.0.0.1'
|
||
}
|
||
|
||
# Caddy авторизация — добавляется поверх основной
|
||
if self.caddy_token:
|
||
# Caddy Security: готовый base64 токен используется как есть
|
||
headers['Authorization'] = f'Basic {self.caddy_token}'
|
||
logger.debug("Используем Caddy Basic Auth")
|
||
|
||
# Основная авторизация RemnaWave API
|
||
if self.auth_type == "basic" and self.username and self.password:
|
||
credentials = f"{self.username}:{self.password}"
|
||
encoded_credentials = base64.b64encode(credentials.encode()).decode()
|
||
headers['X-Api-Key'] = f"Basic {encoded_credentials}"
|
||
logger.debug("Используем Basic Auth в X-Api-Key заголовке")
|
||
elif self.auth_type == "caddy":
|
||
# Для caddy auth_type основная авторизация уже в Authorization header
|
||
# Но API ключ всё равно нужен для RemnaWave
|
||
if self.api_key:
|
||
headers['X-Api-Key'] = self.api_key
|
||
logger.debug("Используем API ключ для RemnaWave + Caddy авторизацию")
|
||
else:
|
||
# api_key или bearer — стандартный режим
|
||
headers['X-Api-Key'] = self.api_key
|
||
if not self.caddy_token:
|
||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||
logger.debug("Используем API ключ в X-Api-Key заголовке")
|
||
|
||
return headers
|
||
|
||
async def __aenter__(self):
|
||
conn_type = self._detect_connection_type()
|
||
|
||
logger.debug(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||
|
||
headers = self._prepare_auth_headers()
|
||
|
||
cookies = None
|
||
if self.secret_key:
|
||
if ':' in self.secret_key:
|
||
key_name, key_value = self.secret_key.split(':', 1)
|
||
cookies = {key_name: key_value}
|
||
logger.debug(f"Используем куки: {key_name}=***")
|
||
else:
|
||
cookies = {self.secret_key: self.secret_key}
|
||
logger.debug(f"Используем куки: {self.secret_key}=***")
|
||
|
||
connector_kwargs = {}
|
||
|
||
if conn_type == "local":
|
||
logger.debug("Используют локальные заголовки proxy")
|
||
headers.update({
|
||
'X-Forwarded-Host': 'localhost',
|
||
'Host': 'localhost'
|
||
})
|
||
|
||
if self.base_url.startswith('https://'):
|
||
ssl_context = ssl.create_default_context()
|
||
ssl_context.check_hostname = False
|
||
ssl_context.verify_mode = ssl.CERT_NONE
|
||
connector_kwargs['ssl'] = ssl_context
|
||
logger.debug("SSL проверка отключена для локального HTTPS")
|
||
|
||
elif conn_type == "external":
|
||
logger.debug("Используют внешнее подключение с полной SSL проверкой")
|
||
pass
|
||
|
||
connector = aiohttp.TCPConnector(**connector_kwargs)
|
||
|
||
session_kwargs = {
|
||
'timeout': aiohttp.ClientTimeout(total=30),
|
||
'headers': headers,
|
||
'connector': connector
|
||
}
|
||
|
||
if cookies:
|
||
session_kwargs['cookies'] = cookies
|
||
|
||
self.session = aiohttp.ClientSession(**session_kwargs)
|
||
self.authenticated = True
|
||
|
||
return self
|
||
|
||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||
if self.session:
|
||
await self.session.close()
|
||
|
||
async def _make_request(
|
||
self,
|
||
method: str,
|
||
endpoint: str,
|
||
data: Optional[Dict] = None,
|
||
params: Optional[Dict] = None
|
||
) -> Dict:
|
||
if not self.session:
|
||
raise RemnaWaveAPIError("Session not initialized. Use async context manager.")
|
||
|
||
url = f"{self.base_url}{endpoint}"
|
||
|
||
try:
|
||
kwargs = {
|
||
'url': url,
|
||
'params': params
|
||
}
|
||
|
||
if data:
|
||
kwargs['json'] = data
|
||
|
||
async with self.session.request(method, **kwargs) as response:
|
||
response_text = await response.text()
|
||
|
||
try:
|
||
response_data = json.loads(response_text) if response_text else {}
|
||
except json.JSONDecodeError:
|
||
response_data = {'raw_response': response_text}
|
||
|
||
if response.status >= 400:
|
||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||
logger.error(f"API Error {response.status}: {error_message}")
|
||
logger.error(f"Response: {response_text[:500]}")
|
||
raise RemnaWaveAPIError(
|
||
error_message,
|
||
response.status,
|
||
response_data
|
||
)
|
||
|
||
return response_data
|
||
|
||
except aiohttp.ClientError as e:
|
||
logger.error(f"Request failed: {e}")
|
||
raise RemnaWaveAPIError(f"Request failed: {str(e)}")
|
||
|
||
|
||
async def create_user(
|
||
self,
|
||
username: str,
|
||
expire_at: datetime,
|
||
status: UserStatus = UserStatus.ACTIVE,
|
||
traffic_limit_bytes: int = 0,
|
||
traffic_limit_strategy: TrafficLimitStrategy = TrafficLimitStrategy.NO_RESET,
|
||
telegram_id: Optional[int] = None,
|
||
email: Optional[str] = None,
|
||
hwid_device_limit: Optional[int] = None,
|
||
description: Optional[str] = None,
|
||
tag: Optional[str] = None,
|
||
active_internal_squads: Optional[List[str]] = None
|
||
) -> RemnaWaveUser:
|
||
data = {
|
||
'username': username,
|
||
'status': status.value,
|
||
'expireAt': expire_at.isoformat(),
|
||
'trafficLimitBytes': traffic_limit_bytes,
|
||
'trafficLimitStrategy': traffic_limit_strategy.value
|
||
}
|
||
|
||
if telegram_id:
|
||
data['telegramId'] = telegram_id
|
||
if email:
|
||
data['email'] = email
|
||
if hwid_device_limit:
|
||
data['hwidDeviceLimit'] = hwid_device_limit
|
||
if description:
|
||
data['description'] = description
|
||
if tag:
|
||
data['tag'] = tag
|
||
if active_internal_squads:
|
||
data['activeInternalSquads'] = active_internal_squads
|
||
|
||
logger.debug("Создание пользователя в панели: %s", data)
|
||
response = await self._make_request('POST', '/api/users', data)
|
||
user = self._parse_user(response['response'])
|
||
return await self.enrich_user_with_happ_link(user)
|
||
|
||
async def get_user_by_uuid(self, uuid: str) -> Optional[RemnaWaveUser]:
|
||
try:
|
||
response = await self._make_request('GET', f'/api/users/{uuid}')
|
||
user = self._parse_user(response['response'])
|
||
return await self.enrich_user_with_happ_link(user)
|
||
except RemnaWaveAPIError as e:
|
||
if e.status_code == 404:
|
||
return None
|
||
raise
|
||
|
||
async def get_user_by_telegram_id(self, telegram_id: int) -> List[RemnaWaveUser]:
|
||
try:
|
||
response = await self._make_request('GET', f'/api/users/by-telegram-id/{telegram_id}')
|
||
users_data = response.get('response', [])
|
||
if not users_data:
|
||
return []
|
||
users = [self._parse_user(user) for user in users_data]
|
||
return [await self.enrich_user_with_happ_link(u) for u in users]
|
||
except RemnaWaveAPIError as e:
|
||
if e.status_code == 404:
|
||
return []
|
||
raise
|
||
|
||
async def get_user_by_username(self, username: str) -> Optional[RemnaWaveUser]:
|
||
try:
|
||
response = await self._make_request('GET', f'/api/users/by-username/{username}')
|
||
user = self._parse_user(response['response'])
|
||
return await self.enrich_user_with_happ_link(user)
|
||
except RemnaWaveAPIError as e:
|
||
if e.status_code == 404:
|
||
return None
|
||
raise
|
||
|
||
async def update_user(
|
||
self,
|
||
uuid: str,
|
||
status: Optional[UserStatus] = None,
|
||
traffic_limit_bytes: Optional[int] = None,
|
||
traffic_limit_strategy: Optional[TrafficLimitStrategy] = None,
|
||
expire_at: Optional[datetime] = None,
|
||
telegram_id: Optional[int] = None,
|
||
email: Optional[str] = None,
|
||
hwid_device_limit: Optional[int] = None,
|
||
description: Optional[str] = None,
|
||
tag: Optional[str] = None,
|
||
active_internal_squads: Optional[List[str]] = None
|
||
) -> RemnaWaveUser:
|
||
data = {'uuid': uuid}
|
||
|
||
if status:
|
||
data['status'] = status.value
|
||
if traffic_limit_bytes is not None:
|
||
data['trafficLimitBytes'] = traffic_limit_bytes
|
||
if traffic_limit_strategy:
|
||
data['trafficLimitStrategy'] = traffic_limit_strategy.value
|
||
if expire_at:
|
||
data['expireAt'] = expire_at.isoformat()
|
||
if telegram_id is not None:
|
||
data['telegramId'] = telegram_id
|
||
if email is not None:
|
||
data['email'] = email
|
||
if hwid_device_limit is not None:
|
||
data['hwidDeviceLimit'] = hwid_device_limit
|
||
if description is not None:
|
||
data['description'] = description
|
||
if tag is not None:
|
||
data['tag'] = tag
|
||
if active_internal_squads is not None:
|
||
data['activeInternalSquads'] = active_internal_squads
|
||
|
||
response = await self._make_request('PATCH', '/api/users', data)
|
||
user = self._parse_user(response['response'])
|
||
return await self.enrich_user_with_happ_link(user)
|
||
|
||
async def delete_user(self, uuid: str) -> bool:
|
||
response = await self._make_request('DELETE', f'/api/users/{uuid}')
|
||
return response['response']['isDeleted']
|
||
|
||
async def enable_user(self, uuid: str) -> RemnaWaveUser:
|
||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/enable')
|
||
user = self._parse_user(response['response'])
|
||
return await self.enrich_user_with_happ_link(user)
|
||
|
||
async def disable_user(self, uuid: str) -> RemnaWaveUser:
|
||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/disable')
|
||
user = self._parse_user(response['response'])
|
||
return await self.enrich_user_with_happ_link(user)
|
||
|
||
async def reset_user_traffic(self, uuid: str) -> RemnaWaveUser:
|
||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/reset-traffic')
|
||
user = self._parse_user(response['response'])
|
||
return await self.enrich_user_with_happ_link(user)
|
||
|
||
async def revoke_user_subscription(
|
||
self,
|
||
uuid: str,
|
||
new_short_uuid: Optional[str] = None,
|
||
revoke_only_passwords: bool = False
|
||
) -> RemnaWaveUser:
|
||
"""
|
||
Отзывает подписку пользователя (меняет ссылку/пароли).
|
||
|
||
Args:
|
||
uuid: UUID пользователя
|
||
new_short_uuid: Новый короткий UUID (опционально, рекомендуется генерировать автоматически)
|
||
revoke_only_passwords: Если True, меняются только пароли без изменения URL подписки
|
||
"""
|
||
data = {}
|
||
if new_short_uuid:
|
||
data['shortUuid'] = new_short_uuid
|
||
if revoke_only_passwords:
|
||
data['revokeOnlyPasswords'] = True
|
||
|
||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data)
|
||
user = self._parse_user(response['response'])
|
||
return await self.enrich_user_with_happ_link(user)
|
||
|
||
async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> Dict[str, Any]:
|
||
params = {'start': start, 'size': size}
|
||
response = await self._make_request('GET', '/api/users', params=params)
|
||
|
||
users = [self._parse_user(user) for user in response['response']['users']]
|
||
|
||
if enrich_happ_links:
|
||
users = [await self.enrich_user_with_happ_link(u) for u in users]
|
||
|
||
return {
|
||
'users': users,
|
||
'total': response['response']['total']
|
||
}
|
||
|
||
|
||
async def get_internal_squads(self) -> List[RemnaWaveInternalSquad]:
|
||
response = await self._make_request('GET', '/api/internal-squads')
|
||
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
|
||
|
||
async def get_internal_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveInternalSquad]:
|
||
try:
|
||
response = await self._make_request('GET', f'/api/internal-squads/{uuid}')
|
||
return self._parse_internal_squad(response['response'])
|
||
except RemnaWaveAPIError as e:
|
||
if e.status_code == 404:
|
||
return None
|
||
raise
|
||
|
||
async def create_internal_squad(self, name: str, inbounds: List[str]) -> RemnaWaveInternalSquad:
|
||
data = {
|
||
'name': name,
|
||
'inbounds': inbounds
|
||
}
|
||
response = await self._make_request('POST', '/api/internal-squads', data)
|
||
return self._parse_internal_squad(response['response'])
|
||
|
||
async def update_internal_squad(
|
||
self,
|
||
uuid: str,
|
||
name: Optional[str] = None,
|
||
inbounds: Optional[List[str]] = None
|
||
) -> RemnaWaveInternalSquad:
|
||
data = {'uuid': uuid}
|
||
if name:
|
||
data['name'] = name
|
||
if inbounds is not None:
|
||
data['inbounds'] = inbounds
|
||
|
||
response = await self._make_request('PATCH', '/api/internal-squads', data)
|
||
return self._parse_internal_squad(response['response'])
|
||
|
||
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']]
|
||
|
||
# ============== External Squads API ==============
|
||
|
||
async def get_external_squads(self) -> List[RemnaWaveExternalSquad]:
|
||
"""Получает список всех External Squads"""
|
||
response = await self._make_request('GET', '/api/external-squads')
|
||
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
|
||
|
||
async def get_external_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveExternalSquad]:
|
||
"""Получает External Squad по UUID"""
|
||
try:
|
||
response = await self._make_request('GET', f'/api/external-squads/{uuid}')
|
||
return self._parse_external_squad(response['response'])
|
||
except RemnaWaveAPIError as e:
|
||
if e.status_code == 404:
|
||
return None
|
||
raise
|
||
|
||
async def create_external_squad(self, name: str) -> RemnaWaveExternalSquad:
|
||
data = {'name': name}
|
||
response = await self._make_request('POST', '/api/external-squads', data)
|
||
return self._parse_external_squad(response['response'])
|
||
|
||
async def update_external_squad(
|
||
self,
|
||
uuid: str,
|
||
name: Optional[str] = None,
|
||
templates: Optional[List[Dict[str, str]]] = None,
|
||
subscription_settings: Optional[Dict[str, Any]] = None,
|
||
host_overrides: Optional[Dict[str, Any]] = None,
|
||
response_headers: Optional[Dict[str, str]] = None,
|
||
hwid_settings: Optional[Dict[str, Any]] = None,
|
||
custom_remarks: Optional[Dict[str, Any]] = None,
|
||
subpage_config_uuid: Optional[str] = None
|
||
) -> RemnaWaveExternalSquad:
|
||
data = {'uuid': uuid}
|
||
if name is not None:
|
||
data['name'] = name
|
||
if templates is not None:
|
||
data['templates'] = templates
|
||
if subscription_settings is not None:
|
||
data['subscriptionSettings'] = subscription_settings
|
||
if host_overrides is not None:
|
||
data['hostOverrides'] = host_overrides
|
||
if response_headers is not None:
|
||
data['responseHeaders'] = response_headers
|
||
if hwid_settings is not None:
|
||
data['hwidSettings'] = hwid_settings
|
||
if custom_remarks is not None:
|
||
data['customRemarks'] = custom_remarks
|
||
if subpage_config_uuid is not None:
|
||
data['subpageConfigUuid'] = subpage_config_uuid
|
||
|
||
response = await self._make_request('PATCH', '/api/external-squads', data)
|
||
return self._parse_external_squad(response['response'])
|
||
|
||
async def delete_external_squad(self, uuid: str) -> bool:
|
||
"""Удаляет External Squad"""
|
||
response = await self._make_request('DELETE', f'/api/external-squads/{uuid}')
|
||
return response['response']['isDeleted']
|
||
|
||
async def add_users_to_external_squad(self, uuid: str) -> bool:
|
||
"""Добавляет всех пользователей в External Squad (bulk action)"""
|
||
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/add-users')
|
||
return response['response']['eventSent']
|
||
|
||
async def remove_users_from_external_squad(self, uuid: str) -> bool:
|
||
"""Удаляет всех пользователей из External Squad (bulk action)"""
|
||
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/remove-users')
|
||
return response['response']['eventSent']
|
||
|
||
async def reorder_external_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveExternalSquad]:
|
||
data = {'items': items}
|
||
response = await self._make_request('POST', '/api/external-squads/actions/reorder', data)
|
||
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
|
||
|
||
def _parse_external_squad(self, squad_data: Dict) -> RemnaWaveExternalSquad:
|
||
"""Парсит данные External Squad"""
|
||
info = squad_data.get('info', {})
|
||
return RemnaWaveExternalSquad(
|
||
uuid=squad_data['uuid'],
|
||
name=squad_data['name'],
|
||
view_position=squad_data.get('viewPosition', 0),
|
||
members_count=info.get('membersCount', 0),
|
||
templates=squad_data.get('templates', []),
|
||
subscription_settings=squad_data.get('subscriptionSettings'),
|
||
host_overrides=squad_data.get('hostOverrides'),
|
||
response_headers=squad_data.get('responseHeaders'),
|
||
hwid_settings=squad_data.get('hwidSettings'),
|
||
custom_remarks=squad_data.get('customRemarks'),
|
||
subpage_config_uuid=squad_data.get('subpageConfigUuid'),
|
||
created_at=self._parse_optional_datetime(squad_data.get('createdAt')),
|
||
updated_at=self._parse_optional_datetime(squad_data.get('updatedAt'))
|
||
)
|
||
|
||
|
||
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']]
|
||
|
||
async def get_node_by_uuid(self, uuid: str) -> Optional[RemnaWaveNode]:
|
||
try:
|
||
response = await self._make_request('GET', f'/api/nodes/{uuid}')
|
||
return self._parse_node(response['response'])
|
||
except RemnaWaveAPIError as e:
|
||
if e.status_code == 404:
|
||
return None
|
||
raise
|
||
|
||
async def enable_node(self, uuid: str) -> RemnaWaveNode:
|
||
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/enable')
|
||
return self._parse_node(response['response'])
|
||
|
||
async def disable_node(self, uuid: str) -> RemnaWaveNode:
|
||
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/disable')
|
||
return self._parse_node(response['response'])
|
||
|
||
async def restart_node(self, uuid: str) -> bool:
|
||
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/restart')
|
||
return response['response']['eventSent']
|
||
|
||
async def restart_all_nodes(self) -> bool:
|
||
response = await self._make_request('POST', '/api/nodes/actions/restart-all')
|
||
return response['response']['eventSent']
|
||
|
||
|
||
async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo:
|
||
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
|
||
info = self._parse_subscription_info(response['response'])
|
||
# Обогащаем happ_crypto_link если его нет но есть subscription_url
|
||
if not info.happ_crypto_link and info.subscription_url:
|
||
encrypted = await self.encrypt_happ_crypto_link(info.subscription_url)
|
||
if encrypted:
|
||
info.happ_crypto_link = encrypted
|
||
return info
|
||
|
||
async def get_subscription_by_short_uuid(self, short_uuid: str) -> str:
|
||
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}") as response:
|
||
if response.status >= 400:
|
||
raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}")
|
||
return await response.text()
|
||
|
||
async def get_subscription_by_client_type(self, short_uuid: str, client_type: str) -> str:
|
||
valid_types = ["stash", "singbox", "singbox-legacy", "mihomo", "json", "v2ray-json", "clash"]
|
||
if client_type not in valid_types:
|
||
raise ValueError(f"Invalid client type. Must be one of: {valid_types}")
|
||
|
||
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}/{client_type}") as response:
|
||
if response.status >= 400:
|
||
raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}")
|
||
return await response.text()
|
||
|
||
async def get_subscription_links(self, short_uuid: str) -> Dict[str, str]:
|
||
base_url = f"{self.base_url}/api/sub/{short_uuid}"
|
||
|
||
links = {
|
||
"base": base_url,
|
||
"stash": f"{base_url}/stash",
|
||
"singbox": f"{base_url}/singbox",
|
||
"singbox_legacy": f"{base_url}/singbox-legacy",
|
||
"mihomo": f"{base_url}/mihomo",
|
||
"json": f"{base_url}/json",
|
||
"v2ray_json": f"{base_url}/v2ray-json",
|
||
"clash": f"{base_url}/clash"
|
||
}
|
||
|
||
return links
|
||
|
||
async def get_outline_subscription(self, short_uuid: str, encoded_tag: str) -> str:
|
||
async with self.session.get(f"{self.base_url}/api/sub/outline/{short_uuid}/ss/{encoded_tag}") as response:
|
||
if response.status >= 400:
|
||
raise RemnaWaveAPIError(f"Failed to get outline subscription: {response.status}")
|
||
return await response.text()
|
||
|
||
|
||
async def get_system_stats(self) -> Dict[str, Any]:
|
||
response = await self._make_request('GET', '/api/system/stats')
|
||
return response['response']
|
||
|
||
async def get_system_metadata(self) -> Dict[str, Any]:
|
||
"""
|
||
Получает метаданные системы Remnawave.
|
||
|
||
Returns:
|
||
Dict с полями:
|
||
- version: версия Remnawave
|
||
- build: {time, number} - информация о сборке
|
||
- git: {backend: {commitSha}, node: {commitSha}} - информация о коммитах
|
||
"""
|
||
response = await self._make_request('GET', '/api/system/metadata')
|
||
return response['response']
|
||
|
||
async def get_bandwidth_stats(self) -> Dict[str, Any]:
|
||
response = await self._make_request('GET', '/api/system/stats/bandwidth')
|
||
return response['response']
|
||
|
||
async def get_nodes_statistics(self) -> Dict[str, Any]:
|
||
response = await self._make_request('GET', '/api/system/stats/nodes')
|
||
return response['response']
|
||
|
||
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
|
||
return await self.get_bandwidth_stats_nodes_realtime()
|
||
|
||
async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> Dict[str, Any]:
|
||
return await self.get_bandwidth_stats_user_legacy(user_uuid, start_date, end_date)
|
||
|
||
# ============== Bandwidth Stats API ==============
|
||
|
||
async def get_bandwidth_stats_nodes(self, start_date: str, end_date: str) -> Dict[str, Any]:
|
||
params = {
|
||
'start': start_date,
|
||
'end': end_date
|
||
}
|
||
response = await self._make_request('GET', '/api/bandwidth-stats/nodes', params=params)
|
||
return response['response']
|
||
|
||
async def get_bandwidth_stats_nodes_realtime(self) -> List[Dict[str, Any]]:
|
||
response = await self._make_request('GET', '/api/bandwidth-stats/nodes/realtime')
|
||
return response['response']
|
||
|
||
async def get_bandwidth_stats_node_users(
|
||
self,
|
||
node_uuid: str,
|
||
start_date: str,
|
||
end_date: str,
|
||
top_users_limit: int = 10
|
||
) -> Dict[str, Any]:
|
||
params = {
|
||
'start': start_date,
|
||
'end': end_date,
|
||
'topUsersLimit': top_users_limit
|
||
}
|
||
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users', params=params)
|
||
return response['response']
|
||
|
||
async def get_bandwidth_stats_node_users_legacy(
|
||
self,
|
||
node_uuid: str,
|
||
start_date: str,
|
||
end_date: str
|
||
) -> Dict[str, Any]:
|
||
params = {
|
||
'start': start_date,
|
||
'end': end_date
|
||
}
|
||
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy', params=params)
|
||
return response['response']
|
||
|
||
async def get_bandwidth_stats_user(
|
||
self,
|
||
user_uuid: str,
|
||
start_date: str,
|
||
end_date: str
|
||
) -> Dict[str, Any]:
|
||
params = {
|
||
'start': start_date,
|
||
'end': end_date
|
||
}
|
||
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}', params=params)
|
||
return response['response']
|
||
|
||
async def get_bandwidth_stats_user_legacy(
|
||
self,
|
||
user_uuid: str,
|
||
start_date: str,
|
||
end_date: str
|
||
) -> Dict[str, Any]:
|
||
params = {
|
||
'start': start_date,
|
||
'end': end_date
|
||
}
|
||
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}/legacy', params=params)
|
||
return response
|
||
|
||
# ============== Subscription Page Configs API ==============
|
||
|
||
async def get_subscription_page_configs(self) -> List[SubscriptionPageConfig]:
|
||
response = await self._make_request('GET', '/api/subscription-page-configs')
|
||
configs_data = response['response'].get('configs', [])
|
||
return [self._parse_subscription_page_config(c) for c in configs_data]
|
||
|
||
async def get_subscription_page_config(self, uuid: str) -> Optional[SubscriptionPageConfig]:
|
||
try:
|
||
response = await self._make_request('GET', f'/api/subscription-page-configs/{uuid}')
|
||
return self._parse_subscription_page_config(response['response'])
|
||
except RemnaWaveAPIError as e:
|
||
if e.status_code == 404:
|
||
return None
|
||
raise
|
||
|
||
async def create_subscription_page_config(self, name: str) -> SubscriptionPageConfig:
|
||
data = {'name': name}
|
||
response = await self._make_request('POST', '/api/subscription-page-configs', data)
|
||
return self._parse_subscription_page_config(response['response'])
|
||
|
||
async def update_subscription_page_config(
|
||
self,
|
||
uuid: str,
|
||
name: Optional[str] = None,
|
||
config: Optional[Dict[str, Any]] = None
|
||
) -> SubscriptionPageConfig:
|
||
data = {'uuid': uuid}
|
||
if name is not None:
|
||
data['name'] = name
|
||
if config is not None:
|
||
data['config'] = config
|
||
response = await self._make_request('PATCH', '/api/subscription-page-configs', data)
|
||
return self._parse_subscription_page_config(response['response'])
|
||
|
||
async def delete_subscription_page_config(self, uuid: str) -> bool:
|
||
response = await self._make_request('DELETE', f'/api/subscription-page-configs/{uuid}')
|
||
return response['response']['isDeleted']
|
||
|
||
async def reorder_subscription_page_configs(self, items: List[Dict[str, Any]]) -> List[SubscriptionPageConfig]:
|
||
data = {'items': items}
|
||
response = await self._make_request('POST', '/api/subscription-page-configs/actions/reorder', data)
|
||
configs_data = response['response'].get('configs', [])
|
||
return [self._parse_subscription_page_config(c) for c in configs_data]
|
||
|
||
async def clone_subscription_page_config(self, clone_from_uuid: str) -> SubscriptionPageConfig:
|
||
data = {'cloneFromUuid': clone_from_uuid}
|
||
response = await self._make_request('POST', '/api/subscription-page-configs/actions/clone', data)
|
||
return self._parse_subscription_page_config(response['response'])
|
||
|
||
async def get_subpage_config_by_short_uuid(self, short_uuid: str) -> Optional[Dict[str, Any]]:
|
||
try:
|
||
response = await self._make_request('GET', f'/api/subscriptions/subpage-config/{short_uuid}')
|
||
return response.get('response')
|
||
except RemnaWaveAPIError as e:
|
||
if e.status_code == 404:
|
||
return None
|
||
raise
|
||
|
||
def _parse_subscription_page_config(self, data: Dict) -> SubscriptionPageConfig:
|
||
"""Парсит данные конфигурации страницы подписки"""
|
||
return SubscriptionPageConfig(
|
||
uuid=data['uuid'],
|
||
name=data['name'],
|
||
view_position=data['viewPosition'],
|
||
config=data.get('config')
|
||
)
|
||
|
||
|
||
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
|
||
try:
|
||
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
|
||
return response['response']
|
||
except RemnaWaveAPIError as e:
|
||
if e.status_code == 404:
|
||
return {'total': 0, 'devices': []}
|
||
raise
|
||
|
||
async def reset_user_devices(self, user_uuid: str) -> bool:
|
||
try:
|
||
devices_info = await self.get_user_devices(user_uuid)
|
||
devices = devices_info.get('devices', [])
|
||
|
||
if not devices:
|
||
return True
|
||
|
||
failed_count = 0
|
||
for device in devices:
|
||
device_hwid = device.get('hwid')
|
||
if device_hwid:
|
||
try:
|
||
delete_data = {
|
||
"userUuid": user_uuid,
|
||
"hwid": device_hwid
|
||
}
|
||
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
|
||
except Exception as device_error:
|
||
logger.error(f"Ошибка удаления устройства {device_hwid}: {device_error}")
|
||
failed_count += 1
|
||
|
||
return failed_count < len(devices) / 2
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при сбросе устройств: {e}")
|
||
return False
|
||
|
||
async def remove_device(self, user_uuid: str, device_hwid: str) -> bool:
|
||
try:
|
||
delete_data = {
|
||
"userUuid": user_uuid,
|
||
"hwid": device_hwid
|
||
}
|
||
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
|
||
return False
|
||
|
||
async def encrypt_happ_crypto_link(self, link_to_encrypt: str) -> Optional[str]:
|
||
try:
|
||
data = {"linkToEncrypt": link_to_encrypt}
|
||
response = await self._make_request('POST', '/api/system/tools/happ/encrypt', data)
|
||
return response.get('response', {}).get('encryptedLink')
|
||
except RemnaWaveAPIError as e:
|
||
logger.warning(f"Не удалось зашифровать happ ссылку: {e.message}")
|
||
return None
|
||
except Exception as e:
|
||
logger.warning(f"Ошибка при шифровании happ ссылки: {e}")
|
||
return None
|
||
|
||
async def enrich_user_with_happ_link(self, user: RemnaWaveUser) -> RemnaWaveUser:
|
||
if not user.happ_crypto_link and user.subscription_url:
|
||
encrypted = await self.encrypt_happ_crypto_link(user.subscription_url)
|
||
if encrypted:
|
||
user.happ_crypto_link = encrypted
|
||
return user
|
||
|
||
|
||
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=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.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')),
|
||
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'),
|
||
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
|
||
happ_link=happ_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]:
|
||
if date_str:
|
||
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=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.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'),
|
||
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:
|
||
happ_data = 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')
|
||
|
||
return SubscriptionInfo(
|
||
is_found=data['isFound'],
|
||
user=data.get('user'),
|
||
links=data.get('links', []),
|
||
ss_conf_links=data.get('ssConfLinks', {}),
|
||
subscription_url=data.get('subscriptionUrl', ''),
|
||
happ=data.get('happ'),
|
||
happ_link=happ_link,
|
||
happ_crypto_link=happ_crypto_link
|
||
)
|
||
|
||
|
||
def format_bytes(bytes_value: int) -> str:
|
||
if bytes_value == 0:
|
||
return "0 B"
|
||
|
||
units = ["B", "KB", "MB", "GB", "TB"]
|
||
size = bytes_value
|
||
unit_index = 0
|
||
|
||
while size >= 1024 and unit_index < len(units) - 1:
|
||
size /= 1024
|
||
unit_index += 1
|
||
|
||
return f"{size:.1f} {units[unit_index]}"
|
||
|
||
|
||
def parse_bytes(size_str: str) -> int:
|
||
size_str = size_str.upper().strip()
|
||
|
||
units = {
|
||
'B': 1,
|
||
'KB': 1024,
|
||
'MB': 1024 ** 2,
|
||
'GB': 1024 ** 3,
|
||
'TB': 1024 ** 4
|
||
}
|
||
|
||
for unit, multiplier in units.items():
|
||
if size_str.endswith(unit):
|
||
try:
|
||
value = float(size_str[:-len(unit)].strip())
|
||
return int(value * multiplier)
|
||
except ValueError:
|
||
break
|
||
|
||
return 0
|
||
|
||
|
||
async def test_api_connection(api: RemnaWaveAPI) -> bool:
|
||
try:
|
||
await api.get_system_stats()
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"API connection test failed: {e}")
|
||
return False
|