diff --git a/.env.example b/.env.example index 6a5b24fc..b92cb0a6 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,14 @@ REMNAWAVE_SECRET_KEY= # {telegram_id} — ID Telegram REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}" +# Шаблон имени пользователя в панели Remnawave +# Доступные плейсхолдеры аналогичны описанию выше +# {full_name} — Имя, Фамилия из Telegram +# {username} — @логин из Telegram (c @) +# {username_clean} — логин из Telegram (без @) +# {telegram_id} — ID Telegram +REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}" + # Режим удаления пользователей из панели RemnaWave # delete - полностью удалить пользователя из панели # disable - только деактивировать пользователя diff --git a/README.md b/README.md index 77eb9951..d08f3dcd 100644 --- a/README.md +++ b/README.md @@ -538,6 +538,8 @@ REMNAWAVE_AUTO_SYNC_TIMES=03:00,15:00 # Шаблон описания пользователя REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}" +# Шаблон имени пользователя в панели +REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}" REMNAWAVE_USER_DELETE_MODE=delete # ===== ПОДПИСКИ ===== diff --git a/app/config.py b/app/config.py index 35a30377..12d9f9ea 100644 --- a/app/config.py +++ b/app/config.py @@ -73,6 +73,7 @@ class Settings(BaseSettings): REMNAWAVE_PASSWORD: Optional[str] = None REMNAWAVE_AUTH_TYPE: str = "api_key" REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}" + REMNAWAVE_USER_USERNAME_TEMPLATE: str = "user_{telegram_id}" REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable" REMNAWAVE_AUTO_SYNC_ENABLED: bool = False REMNAWAVE_AUTO_SYNC_TIMES: str = "03:00" @@ -551,6 +552,34 @@ class Settings(BaseSettings): description = re.sub(r'\s+', ' ', description).strip() return description + def format_remnawave_username( + self, + *, + full_name: str, + username: Optional[str], + telegram_id: int + ) -> str: + template = self.REMNAWAVE_USER_USERNAME_TEMPLATE or "user_{telegram_id}" + + username_clean = (username or "").lstrip("@") + full_name_value = full_name or "" + + values = defaultdict(str, { + "full_name": full_name_value, + "username": username_clean, + "username_clean": username_clean, + "telegram_id": str(telegram_id), + }) + + raw_username = template.format_map(values).strip() + sanitized_username = re.sub(r"[^0-9A-Za-z._-]+", "_", raw_username) + sanitized_username = re.sub(r"_+", "_", sanitized_username).strip("._-") + + if not sanitized_username: + sanitized_username = f"user_{telegram_id}" + + return sanitized_username[:64] + @staticmethod def parse_daily_time_list(raw_value: Optional[str]) -> List[time]: if not raw_value: diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 78fd9276..ed18af5d 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -3878,7 +3878,11 @@ async def admin_buy_subscription_execute( remnawave_user = await api.update_user(**update_kwargs) else: - username = f"user_{target_user.telegram_id}" + username = settings.format_remnawave_username( + full_name=target_user.full_name, + username=target_user.username, + telegram_id=target_user.telegram_id, + ) async with remnawave_service.get_api_client() as api: create_kwargs = dict( username=username, diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 71c31eee..34309614 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -1245,7 +1245,11 @@ class RemnaWaveService: await api.update_user(**update_kwargs) stats["updated"] += 1 else: - username = f"user_{user.telegram_id}" + username = settings.format_remnawave_username( + full_name=user.full_name, + username=user.username, + telegram_id=user.telegram_id, + ) create_kwargs = dict( username=username, diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 08be31cb..36fb3074 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -216,7 +216,11 @@ class SubscriptionService: else: logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}") - username = f"user_{user.telegram_id}" + username = settings.format_remnawave_username( + full_name=user.full_name, + username=user.username, + telegram_id=user.telegram_id, + ) create_kwargs = dict( username=username, expire_at=subscription.end_date, diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index e493a97c..9666ba6a 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -269,6 +269,7 @@ class BotConfigurationService: "VERSION_CHECK_INTERVAL_HOURS": "VERSION", "TELEGRAM_STARS_RATE_RUB": "TELEGRAM", "REMNAWAVE_USER_DESCRIPTION_TEMPLATE": "REMNAWAVE", + "REMNAWAVE_USER_USERNAME_TEMPLATE": "REMNAWAVE", "REMNAWAVE_AUTO_SYNC_ENABLED": "REMNAWAVE", "REMNAWAVE_AUTO_SYNC_TIMES": "REMNAWAVE", } @@ -552,6 +553,31 @@ class BotConfigurationService: ), "dependencies": "REMNAWAVE_AUTO_SYNC_ENABLED", }, + "REMNAWAVE_USER_DESCRIPTION_TEMPLATE": { + "description": ( + "Шаблон текста, который бот передает в поле Description при создании " + "или обновлении пользователя в панели RemnaWave." + ), + "format": ( + "Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}." + ), + "example": "Bot user: {full_name} {username}", + "warning": "Плейсхолдер {username} автоматически очищается, если у пользователя нет @username.", + }, + "REMNAWAVE_USER_USERNAME_TEMPLATE": { + "description": ( + "Шаблон имени пользователя, которое создаётся в панели RemnaWave для " + "телеграм-пользователя." + ), + "format": ( + "Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}." + ), + "example": "vpn_{username_clean}_{telegram_id}", + "warning": ( + "Недопустимые символы автоматически заменяются на подчёркивания. " + "Если результат пустой, используется user_{telegram_id}." + ), + }, "EXTERNAL_ADMIN_TOKEN": { "description": "Приватный токен, который использует внешняя админка для проверки запросов.", "format": "Значение генерируется автоматически из username бота и его токена и доступно только для чтения.",