Merge pull request #2129 from BEDOLAGA-DEV/dev5

Обновление Api Remnawave под версию 2.3.0 + Возможность установить таги для триалов и платных подписок + Правки Web Api
This commit is contained in:
Egor
2025-12-08 04:28:00 +03:00
committed by GitHub
8 changed files with 512 additions and 103 deletions

View File

@@ -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:

View File

@@ -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:

View File

@@ -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"""
🖥️ <b>Нода: {node['name']}</b>
@@ -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 ''}
<b>Информация:</b>
- Адрес: {node['address']}
- Страна: {node['country_code']}
- Пользователей онлайн: {node['users_online']}
- CPU: {cpu_info}
- RAM: {node.get('total_ram') or ''}
- Провайдер: {node.get('provider_uuid') or ''}
<b>Трафик:</b>
- Использовано: {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}
<b>Метаданные:</b>
- Создана: {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"""
📊 <b>Статистика ноды: {node['name']}</b>
@@ -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 ''}
<b>Ресурсы:</b>
- CPU: {cpu_info}
- RAM: {node.get('total_ram') or ''}
- Провайдер: {node.get('provider_uuid') or ''}
<b>Трафик:</b>
- Использовано: {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}
<b>Метаданные:</b>
- Создана: {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"""
📊 <b>Статистика ноды: {node['name']}</b>
<b>Статус:</b>
- Онлайн: {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 ''}
<b>Трафик:</b>
- Использовано: {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}
⚠️ <b>Детальная статистика временно недоступна</b>
Возможные причины:
@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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):