first com2
This commit is contained in:
320
services/rw.py
Normal file
320
services/rw.py
Normal file
@@ -0,0 +1,320 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from remnawave import RemnawaveSDK
|
||||
from remnawave.models import UpdateUserRequestDto
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Dataclass — унифицированное представление пользователя RW
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class RWUserInfo:
|
||||
uuid: str
|
||||
username: str
|
||||
status: str # "ACTIVE", "DISABLED", "EXPIRED", "ON_HOLD", "LIMITED"
|
||||
expire_at: datetime
|
||||
used_traffic_bytes: float
|
||||
traffic_limit_bytes: int # 0 = unlimited
|
||||
hwid_device_limit: Optional[int] # None = unlimited
|
||||
active_squads: list[dict] # [{"uuid": "...", "name": "..."}]
|
||||
description: Optional[str]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Внутренние хелперы
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_user(user_dto) -> RWUserInfo:
|
||||
"""Преобразовать UserResponseDto → RWUserInfo."""
|
||||
active_squads: list[dict] = []
|
||||
for squad in user_dto.active_internal_squads or []:
|
||||
active_squads.append({"uuid": str(squad.uuid), "name": squad.name})
|
||||
|
||||
# UserStatus может быть enum-объектом или строкой — нормализуем
|
||||
status_raw = user_dto.status
|
||||
if hasattr(status_raw, "value"):
|
||||
status_str = str(status_raw.value).upper()
|
||||
else:
|
||||
status_str = str(status_raw).upper()
|
||||
|
||||
# Трафик лежит в user_traffic.used_traffic_bytes
|
||||
used_traffic: float = 0.0
|
||||
if user_dto.user_traffic is not None:
|
||||
used_traffic = float(user_dto.user_traffic.used_traffic_bytes or 0)
|
||||
|
||||
return RWUserInfo(
|
||||
uuid=str(user_dto.uuid),
|
||||
username=user_dto.username,
|
||||
status=status_str,
|
||||
expire_at=user_dto.expire_at,
|
||||
used_traffic_bytes=used_traffic,
|
||||
traffic_limit_bytes=int(user_dto.traffic_limit_bytes or 0),
|
||||
hwid_device_limit=user_dto.hwid_device_limit,
|
||||
active_squads=active_squads,
|
||||
description=user_dto.description,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Публичные функции — обёртки с перехватом ошибок
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def get_user_by_telegram_id(
|
||||
sdk: RemnawaveSDK,
|
||||
telegram_id: int,
|
||||
) -> Optional[RWUserInfo]:
|
||||
"""
|
||||
Получить первого пользователя Remnawave, привязанного к Telegram ID.
|
||||
Возвращает None, если пользователь не найден или Remnawave недоступен.
|
||||
"""
|
||||
try:
|
||||
users_list = await sdk.users.get_users_by_telegram_id(str(telegram_id))
|
||||
if not users_list:
|
||||
return None
|
||||
return _parse_user(users_list[0])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to get RW user by telegram_id=%s: %s",
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def get_all_squads(sdk: RemnawaveSDK) -> list[dict]:
|
||||
"""
|
||||
Получить все Internal Squads из Remnawave.
|
||||
Возвращает список [{"uuid": "...", "name": "..."}].
|
||||
При ошибке возвращает пустой список.
|
||||
"""
|
||||
try:
|
||||
response = await sdk.internal_squads.get_internal_squads()
|
||||
result: list[dict] = []
|
||||
# GetAllInternalSquadsResponseDto имеет поле .internal_squads
|
||||
for squad in response.internal_squads:
|
||||
result.append({"uuid": str(squad.uuid), "name": squad.name})
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to get all internal squads: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
async def add_days(
|
||||
sdk: RemnawaveSDK,
|
||||
user_uuid: str,
|
||||
days: int,
|
||||
) -> Optional[datetime]:
|
||||
"""
|
||||
Добавить N дней к expire_at пользователя.
|
||||
Возвращает новую дату окончания или None при ошибке.
|
||||
"""
|
||||
try:
|
||||
# Получаем актуальный expire_at — нельзя слепо использовать
|
||||
# закешированное значение, чтобы не потерять изменения других агентов
|
||||
user_dto = await sdk.users.get_user_by_uuid(uuid=user_uuid)
|
||||
if user_dto is None or user_dto.expire_at is None:
|
||||
logger.warning("Cannot add days: user %s has no expire_at", user_uuid)
|
||||
return None
|
||||
|
||||
# Обеспечиваем timezone-aware datetime
|
||||
current_expire = user_dto.expire_at
|
||||
if current_expire.tzinfo is None:
|
||||
current_expire = current_expire.replace(tzinfo=timezone.utc)
|
||||
|
||||
new_expire = current_expire + timedelta(days=days)
|
||||
|
||||
result = await sdk.users.update_user(
|
||||
UpdateUserRequestDto(
|
||||
uuid=UUID(user_uuid),
|
||||
expire_at=new_expire,
|
||||
)
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
new_dt = result.expire_at
|
||||
if new_dt is not None and new_dt.tzinfo is None:
|
||||
new_dt = new_dt.replace(tzinfo=timezone.utc)
|
||||
return new_dt
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to add %d days for user %s: %s", days, user_uuid, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def remove_days(
|
||||
sdk: RemnawaveSDK,
|
||||
user_uuid: str,
|
||||
days: int,
|
||||
) -> Optional[datetime]:
|
||||
"""
|
||||
Вычесть N дней из expire_at пользователя.
|
||||
Возвращает новую дату окончания или None при ошибке.
|
||||
"""
|
||||
try:
|
||||
user_dto = await sdk.users.get_user_by_uuid(uuid=user_uuid)
|
||||
if user_dto is None or user_dto.expire_at is None:
|
||||
logger.warning("Cannot remove days: user %s has no expire_at", user_uuid)
|
||||
return None
|
||||
|
||||
current_expire = user_dto.expire_at
|
||||
if current_expire.tzinfo is None:
|
||||
current_expire = current_expire.replace(tzinfo=timezone.utc)
|
||||
|
||||
new_expire = current_expire - timedelta(days=days)
|
||||
|
||||
result = await sdk.users.update_user(
|
||||
UpdateUserRequestDto(
|
||||
uuid=UUID(user_uuid),
|
||||
expire_at=new_expire,
|
||||
)
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
new_dt = result.expire_at
|
||||
if new_dt is not None and new_dt.tzinfo is None:
|
||||
new_dt = new_dt.replace(tzinfo=timezone.utc)
|
||||
return new_dt
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to remove %d days for user %s: %s", days, user_uuid, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def set_hwid_limit(
|
||||
sdk: RemnawaveSDK,
|
||||
user_uuid: str,
|
||||
limit: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Установить лимит HWID-устройств.
|
||||
limit == 0 → убрать ограничение (None).
|
||||
Возвращает True при успехе, False при ошибке.
|
||||
"""
|
||||
try:
|
||||
hwid_value: Optional[int] = None if limit == 0 else limit
|
||||
await sdk.users.update_user(
|
||||
UpdateUserRequestDto(
|
||||
uuid=UUID(user_uuid),
|
||||
hwid_device_limit=hwid_value,
|
||||
)
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to set HWID limit=%s for user %s: %s",
|
||||
limit,
|
||||
user_uuid,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def set_description(
|
||||
sdk: RemnawaveSDK,
|
||||
user_uuid: str,
|
||||
note: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Установить заметку (description) пользователя в Remnawave.
|
||||
Возвращает True при успехе, False при ошибке.
|
||||
"""
|
||||
try:
|
||||
await sdk.users.update_user(
|
||||
UpdateUserRequestDto(
|
||||
uuid=UUID(user_uuid),
|
||||
description=note,
|
||||
)
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to set description for user %s: %s",
|
||||
user_uuid,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def reset_traffic(
|
||||
sdk: RemnawaveSDK,
|
||||
user_uuid: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Сбросить счётчик использованного трафика пользователя.
|
||||
Возвращает True при успехе, False при ошибке.
|
||||
"""
|
||||
try:
|
||||
await sdk.users.reset_user_traffic(uuid=user_uuid)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to reset traffic for user %s: %s", user_uuid, exc)
|
||||
return False
|
||||
|
||||
|
||||
async def disable_user(
|
||||
sdk: RemnawaveSDK,
|
||||
user_uuid: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Отключить пользователя (статус → DISABLED).
|
||||
Возвращает True при успехе, False при ошибке.
|
||||
"""
|
||||
try:
|
||||
await sdk.users.disable_user(uuid=user_uuid)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to disable user %s: %s", user_uuid, exc)
|
||||
return False
|
||||
|
||||
|
||||
async def enable_user(
|
||||
sdk: RemnawaveSDK,
|
||||
user_uuid: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Включить пользователя (статус → ACTIVE).
|
||||
Возвращает True при успехе, False при ошибке.
|
||||
"""
|
||||
try:
|
||||
await sdk.users.enable_user(uuid=user_uuid)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to enable user %s: %s", user_uuid, exc)
|
||||
return False
|
||||
|
||||
|
||||
async def update_squads(
|
||||
sdk: RemnawaveSDK,
|
||||
user_uuid: str,
|
||||
squad_uuid_list: list[str],
|
||||
) -> bool:
|
||||
"""
|
||||
Полностью заменить список Internal Squads пользователя.
|
||||
squad_uuid_list — строковые UUID всех squad'ов (полный новый список).
|
||||
Возвращает True при успехе, False при ошибке.
|
||||
"""
|
||||
try:
|
||||
await sdk.users.update_user(
|
||||
UpdateUserRequestDto(
|
||||
uuid=UUID(user_uuid),
|
||||
active_internal_squads=[UUID(s) for s in squad_uuid_list],
|
||||
)
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to update squads for user %s: %s",
|
||||
user_uuid,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
@@ -1,14 +1,87 @@
|
||||
from typing import Optional
|
||||
from aiogram.types import Message, ReactionTypeEmoji
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from remnawave import RemnawaveSDK
|
||||
|
||||
from config import settings
|
||||
from db.models.tickets import TicketStatus
|
||||
from db.models.users import User
|
||||
from dto.users import NewUserDTO
|
||||
from dto.tickets import NewTicketDTO
|
||||
from dto.users import NewUserDTO, TicketCreator
|
||||
from repositories import UserRepository
|
||||
from repositories.tickets import TicketRepository
|
||||
from services.rw import RWUserInfo, get_user_by_telegram_id
|
||||
from texts import (
|
||||
ADMIN_STANDARD_FALLBACK,
|
||||
ADMIN_TICKET_WITH_RW,
|
||||
SUPPORT_SIGNATURE,
|
||||
TICKET_STATUS_EMOJIS,
|
||||
ADMIN_TICKET_WITHOUT_RW,
|
||||
)
|
||||
from misc import utils
|
||||
|
||||
|
||||
class UserServiceException(Exception): ...
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(self, user_repository: UserRepository) -> None:
|
||||
def __init__(
|
||||
self, user_repository: UserRepository, ticket_repository: TicketRepository
|
||||
) -> None:
|
||||
self.user_repository = user_repository
|
||||
self.ticket_repository = ticket_repository
|
||||
|
||||
@staticmethod
|
||||
def format_ticket_name(
|
||||
creator: TicketCreator,
|
||||
status: Optional[TicketStatus] = TicketStatus.OPEN,
|
||||
ticket_id: Optional[int] = None,
|
||||
):
|
||||
return (
|
||||
TICKET_STATUS_EMOJIS[status.value]
|
||||
+ (f" T-{ticket_id:0>3}" if ticket_id else "")
|
||||
+ " | "
|
||||
+ (f"@{creator.username}" if creator.username else str(creator.id))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def format_admin_support_message(
|
||||
ticket_number: int, user: User, rw_user: Optional[RWUserInfo]
|
||||
):
|
||||
username_display = utils.format_username(user.username)
|
||||
|
||||
if rw_user is None:
|
||||
# Пользователь не найден в Remnawave
|
||||
return ADMIN_TICKET_WITHOUT_RW.format(
|
||||
ticket_number=ticket_number,
|
||||
full_name=user.full_name,
|
||||
username_display=username_display,
|
||||
telegram_id=user.id,
|
||||
)
|
||||
|
||||
status_icon, status_text = utils.format_status(rw_user.status)
|
||||
expire_date = rw_user.expire_at.strftime("%d.%m.%Y")
|
||||
days_left = utils.format_days_left(rw_user.expire_at)
|
||||
used_traffic = utils.format_traffic(
|
||||
rw_user.used_traffic_bytes, rw_user.traffic_limit_bytes
|
||||
)
|
||||
hwid_limit = utils.format_hwid_limit(rw_user.hwid_device_limit)
|
||||
squads = utils.format_squads(rw_user.active_squads)
|
||||
|
||||
return ADMIN_TICKET_WITH_RW.format(
|
||||
ticket_number=ticket_number,
|
||||
full_name=user.full_name,
|
||||
username_display=username_display,
|
||||
telegram_id=user.id,
|
||||
status_icon=status_icon,
|
||||
status=status_text,
|
||||
expire_date=expire_date,
|
||||
days_left=days_left,
|
||||
used_traffic=used_traffic,
|
||||
hwid_limit=hwid_limit,
|
||||
squads=squads,
|
||||
remnawave_uuid=rw_user.uuid,
|
||||
)
|
||||
|
||||
async def add_user(
|
||||
self, session: AsyncSession, user_id: int, referal: Optional[int] = None
|
||||
@@ -20,3 +93,105 @@ class UserService:
|
||||
referal = int(referal) if str(referal).isdigit() else None
|
||||
model = NewUserDTO(id=user_id, referal=referal)
|
||||
return await self.user_repository.create_user(session, model)
|
||||
|
||||
async def support_forward_message(
|
||||
self, session: AsyncSession, msg: Message, rw: RemnawaveSDK
|
||||
):
|
||||
user = await self.user_repository.get_user_by_id(session, msg.from_user.id)
|
||||
if not user:
|
||||
raise UserServiceException("User %d is not found.", msg.from_user.id)
|
||||
|
||||
ticket = await self.ticket_repository.get_available_ticket_by_user_id(
|
||||
session, user.id
|
||||
)
|
||||
|
||||
if ticket:
|
||||
try:
|
||||
print(ticket.thread_id)
|
||||
await msg.copy_to(
|
||||
chat_id=settings.admin_group_id, message_thread_id=ticket.thread_id
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
await self.ticket_repository.update_ticket_status(
|
||||
session, ticket, TicketStatus.CLOSED
|
||||
)
|
||||
|
||||
rw_user = await get_user_by_telegram_id(rw, user.id)
|
||||
creator = TicketCreator(id=user.id, username=msg.from_user.username)
|
||||
|
||||
ticket_dto = NewTicketDTO(
|
||||
creator_id=user.id,
|
||||
)
|
||||
ticket = await self.ticket_repository.create_ticket(
|
||||
session, ticket_data=ticket_dto
|
||||
)
|
||||
|
||||
thread = await msg.bot.create_forum_topic(
|
||||
settings.admin_group_id,
|
||||
self.format_ticket_name(
|
||||
creator, ticket_id=ticket.id, status=TicketStatus.PENDING
|
||||
),
|
||||
)
|
||||
ticket = await self.ticket_repository.update_ticket_thread_id(
|
||||
session, ticket, thread.message_thread_id
|
||||
)
|
||||
|
||||
await msg.bot.send_message(
|
||||
settings.admin_group_id,
|
||||
message_thread_id=thread.message_thread_id,
|
||||
text=await self.format_admin_support_message(
|
||||
ticket_number=ticket.id, user=msg.from_user, rw_user=rw_user
|
||||
),
|
||||
)
|
||||
await msg.copy_to(
|
||||
settings.admin_group_id, message_thread_id=thread.message_thread_id
|
||||
)
|
||||
|
||||
async def support_answer(self, session: AsyncSession, msg: Message):
|
||||
thread_id = msg.message_thread_id
|
||||
ticket = await self.ticket_repository.get_ticket_by_thread_id(
|
||||
session, thread_id=thread_id
|
||||
)
|
||||
|
||||
if ticket.status == TicketStatus.PENDING:
|
||||
await self.ticket_repository.update_ticket_status(
|
||||
session, ticket, TicketStatus.OPEN
|
||||
)
|
||||
await msg.bot.edit_forum_topic(
|
||||
settings.admin_group_id,
|
||||
ticket.thread_id,
|
||||
name=self.format_ticket_name(
|
||||
TicketCreator(ticket.creator_id), ticket_id=ticket.id
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
await msg.bot.send_message(ticket.creator_id, SUPPORT_SIGNATURE)
|
||||
await msg.copy_to(ticket.creator_id)
|
||||
await msg.react([ReactionTypeEmoji(emoji="⚡")])
|
||||
except Exception:
|
||||
await msg.reply(ADMIN_STANDARD_FALLBACK)
|
||||
|
||||
async def close_ticket(self, session: AsyncSession, msg: Message):
|
||||
thread_id = msg.message_thread_id
|
||||
ticket = await self.ticket_repository.get_ticket_by_thread_id(
|
||||
session, thread_id=thread_id
|
||||
)
|
||||
|
||||
await msg.bot.edit_forum_topic(
|
||||
settings.admin_group_id,
|
||||
ticket.thread_id,
|
||||
name=self.format_ticket_name(
|
||||
TicketCreator(ticket.creator_id),
|
||||
ticket_id=ticket.id,
|
||||
status=TicketStatus.CLOSED,
|
||||
),
|
||||
)
|
||||
|
||||
await self.ticket_repository.update_ticket_status(
|
||||
session, ticket, TicketStatus.CLOSED
|
||||
)
|
||||
await msg.bot.close_forum_topic(
|
||||
settings.admin_group_id, message_thread_id=thread_id
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user