diff --git a/.env.example b/.env.example
index 0fc1e422..ad38c3da 100644
--- a/.env.example
+++ b/.env.example
@@ -396,6 +396,12 @@ MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подкл
# Параметры режима happ_cryptolink
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
+HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED=false
+HAPP_CRYPTOLINK_PROVIDER_CODE=
+HAPP_CRYPTOLINK_AUTH_KEY=
+HAPP_CRYPTOLINK_INSTALL_LIMIT=0
+HAPP_CRYPTOLINK_API_BASE_URL=https://api.happ-proxy.com
+HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES=0
HAPP_DOWNLOAD_LINK_IOS=
HAPP_DOWNLOAD_LINK_ANDROID=
HAPP_DOWNLOAD_LINK_MACOS=
diff --git a/app/config.py b/app/config.py
index 9a3ee4e9..438a84ed 100644
--- a/app/config.py
+++ b/app/config.py
@@ -6,7 +6,7 @@ import os
import re
import html
from collections import defaultdict
-from datetime import time
+from datetime import time, timedelta
from typing import Dict, List, Optional, Union
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
@@ -339,6 +339,12 @@ class Settings(BaseSettings):
MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение"
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None
+ HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED: bool = False
+ HAPP_CRYPTOLINK_PROVIDER_CODE: Optional[str] = None
+ HAPP_CRYPTOLINK_AUTH_KEY: Optional[str] = None
+ HAPP_CRYPTOLINK_INSTALL_LIMIT: int = 0
+ HAPP_CRYPTOLINK_API_BASE_URL: str = "https://api.happ-proxy.com"
+ HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES: int = 0
HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None
HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None
HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None
@@ -1188,6 +1194,50 @@ class Settings(BaseSettings):
def is_happ_download_button_enabled(self) -> bool:
return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED
+ def is_happ_cryptolink_limited_links_enabled(self) -> bool:
+ if not self.is_happ_cryptolink_mode():
+ return False
+
+ if not self.HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED:
+ return False
+
+ provider_code, auth_key = self.get_happ_cryptolink_credentials()
+ return bool(provider_code and auth_key)
+
+ def get_happ_cryptolink_credentials(self) -> tuple[Optional[str], Optional[str]]:
+ provider_code = (self.HAPP_CRYPTOLINK_PROVIDER_CODE or "").strip()
+ auth_key = (self.HAPP_CRYPTOLINK_AUTH_KEY or "").strip()
+ return provider_code or None, auth_key or None
+
+ def get_happ_cryptolink_install_limit(self, fallback_limit: Optional[int] = None) -> Optional[int]:
+ try:
+ limit = int(self.HAPP_CRYPTOLINK_INSTALL_LIMIT)
+ except (TypeError, ValueError):
+ limit = 0
+
+ if limit <= 0:
+ limit = fallback_limit or 0
+
+ if limit <= 0:
+ return None
+
+ return limit
+
+ def get_happ_cryptolink_add_install_url(self) -> str:
+ base_url = (self.HAPP_CRYPTOLINK_API_BASE_URL or "").rstrip("/")
+ return f"{base_url}/api/add-install"
+
+ def get_happ_cryptolink_reset_cooldown(self) -> Optional[timedelta]:
+ try:
+ minutes = int(self.HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES)
+ except (TypeError, ValueError):
+ minutes = 0
+
+ if minutes <= 0:
+ return None
+
+ return timedelta(minutes=max(1, minutes))
+
def should_hide_subscription_link(self) -> bool:
"""Returns True when subscription links must be hidden from the interface."""
diff --git a/app/database/models.py b/app/database/models.py
index 41887159..d6a50988 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -682,7 +682,9 @@ class Subscription(Base):
subscription_crypto_link = Column(String, nullable=True)
device_limit = Column(Integer, default=1)
-
+
+ last_devices_reset_at = Column(DateTime, nullable=True)
+
connected_squads = Column(JSON, default=list)
autopay_enabled = Column(Boolean, default=False)
diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py
index 058e3eab..c23cbbd0 100644
--- a/app/database/universal_migration.py
+++ b/app/database/universal_migration.py
@@ -3120,6 +3120,33 @@ async def add_subscription_crypto_link_column() -> bool:
return False
+async def add_last_devices_reset_column() -> bool:
+ column_exists = await check_column_exists('subscriptions', 'last_devices_reset_at')
+ if column_exists:
+ logger.info("ℹ️ Колонка last_devices_reset_at уже существует")
+ return True
+
+ try:
+ async with engine.begin() as conn:
+ db_type = await get_database_type()
+
+ if db_type == 'sqlite':
+ await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at DATETIME"))
+ elif db_type == 'postgresql':
+ await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at TIMESTAMP"))
+ elif db_type == 'mysql':
+ await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at DATETIME"))
+ else:
+ logger.error(f"Неподдерживаемый тип БД для добавления last_devices_reset_at: {db_type}")
+ return False
+
+ logger.info("✅ Добавлена колонка last_devices_reset_at в таблицу subscriptions")
+ return True
+ except Exception as e:
+ logger.error(f"Ошибка добавления колонки last_devices_reset_at: {e}")
+ return False
+
+
async def fix_foreign_keys_for_user_deletion():
try:
async with engine.begin() as conn:
@@ -4541,6 +4568,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link")
+ logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ LAST_DEVICES_RESET_AT ДЛЯ ПОДПИСОК ===")
+ devices_reset_column_added = await add_last_devices_reset_column()
+ if devices_reset_column_added:
+ logger.info("✅ Колонка last_devices_reset_at готова")
+ else:
+ logger.warning("⚠️ Проблемы с добавлением колонки last_devices_reset_at")
+
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===")
try:
async with engine.begin() as conn:
@@ -4702,6 +4736,7 @@ async def check_migration_status():
"subscription_duplicates": False,
"subscription_conversions_table": False,
"subscription_events_table": False,
+ "subscription_last_devices_reset_column": False,
"promo_groups_table": False,
"server_promo_groups_table": False,
"server_squads_trial_column": False,
@@ -4773,6 +4808,7 @@ async def check_migration_status():
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent')
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
+ status["subscription_last_devices_reset_column"] = await check_column_exists('subscriptions', 'last_devices_reset_at')
media_fields_exist = (
await check_column_exists('broadcast_history', 'has_media') and
@@ -4821,6 +4857,7 @@ async def check_migration_status():
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
+ "subscription_last_devices_reset_column": "Колонка last_devices_reset_at в subscriptions",
"discount_offers_table": "Таблица discount_offers",
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
"discount_offers_extra_column": "Колонка extra_data в discount_offers",
diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py
index 5c145416..f5c23fa2 100644
--- a/app/handlers/admin/users.py
+++ b/app/handlers/admin/users.py
@@ -4004,8 +4004,16 @@ async def reset_user_devices(
remnawave_service = RemnaWaveService()
async with remnawave_service.get_api_client() as api:
success = await api.reset_user_devices(user.remnawave_uuid)
-
+
if success:
+ try:
+ await remnawave_service.refresh_happ_subscription_after_reset(db, user)
+ except Exception as refresh_error:
+ logger.warning(
+ "⚠️ Не удалось обновить Happ ссылку после сброса устройств: %s",
+ refresh_error,
+ )
+
await callback.message.edit_text(
"✅ Устройства пользователя успешно сброшены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py
index cecfdf22..e4cc7144 100644
--- a/app/handlers/subscription/devices.py
+++ b/app/handlers/subscription/devices.py
@@ -1,6 +1,7 @@
import base64
import json
import logging
+import math
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
@@ -81,6 +82,27 @@ from app.utils.promo_offer import (
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, format_additional_section, get_apps_for_device, get_device_name, get_step_description, logger
from .countries import _get_available_countries
+
+def _format_cooldown_duration(seconds: int, language: str) -> str:
+ total_minutes = max(1, math.ceil(seconds / 60))
+ days, remainder_minutes = divmod(total_minutes, 60 * 24)
+ hours, minutes = divmod(remainder_minutes, 60)
+
+ language_code = (language or "ru").split("-")[0].lower()
+ if language_code == "en":
+ day_label, hour_label, minute_label = "d", "h", "m"
+ else:
+ day_label, hour_label, minute_label = "д", "ч", "м"
+
+ parts: list[str] = []
+ if days:
+ parts.append(f"{days}{day_label}")
+ if hours or days:
+ parts.append(f"{hours}{hour_label}")
+ parts.append(f"{minutes}{minute_label}")
+
+ return " ".join(parts)
+
async def get_current_devices_detailed(db_user: User) -> dict:
try:
if not db_user.remnawave_uuid:
@@ -772,6 +794,30 @@ async def handle_all_devices_reset_from_management(
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
+ subscription = getattr(db_user, "subscription", None)
+ cooldown_remaining = (
+ service.get_devices_reset_cooldown_remaining(subscription)
+ if subscription
+ else None
+ )
+
+ if cooldown_remaining:
+ remaining_seconds = int(cooldown_remaining.total_seconds())
+ cooldown = settings.get_happ_cryptolink_reset_cooldown()
+ cooldown_seconds = int(cooldown.total_seconds()) if cooldown else remaining_seconds
+
+ await callback.answer(
+ texts.t(
+ "DEVICE_RESET_COOLDOWN",
+ "⏳ Сброс устройств доступен раз в {cooldown}. Попробуйте через {remaining}.",
+ ).format(
+ cooldown=_format_cooldown_duration(cooldown_seconds, db_user.language),
+ remaining=_format_cooldown_duration(remaining_seconds, db_user.language),
+ ),
+ show_alert=True,
+ )
+ return
+
async with service.get_api_client() as api:
devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
@@ -819,6 +865,15 @@ async def handle_all_devices_reset_from_management(
failed_count += 1
logger.warning(f"⚠️ У устройства нет HWID: {device}")
+ if success_count > 0:
+ try:
+ await service.refresh_happ_subscription_after_reset(db, db_user)
+ except Exception as refresh_error:
+ logger.warning(
+ "⚠️ Не удалось обновить Happ ссылку после сброса устройств: %s",
+ refresh_error,
+ )
+
if success_count > 0:
if failed_count == 0:
await callback.message.edit_text(
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index 02cf3d0f..e388999e 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -982,6 +982,7 @@
"DEVICE_RESET_ALL_DONE": "ℹ️ All devices have been reset",
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Couldn't reset devices\n\nPlease try again later or contact support.\n\nTotal devices: {total}",
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ All devices have been reset!\n\n🔄 Reset: {count} devices\n📱 You can now reconnect your devices\n\n💡 Use the link from the 'My subscription' section to reconnect",
+ "DEVICE_RESET_COOLDOWN": "⏳ Device reset is available every {cooldown}. Try again in {remaining}.",
"DEVICE_RESET_ERROR": "❌ Failed to reset the device",
"DEVICE_RESET_ID_FAILED": "❌ Unable to get device ID",
"DEVICE_RESET_INVALID_REQUEST": "❌ Error: invalid request",
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index e1d2b5cb..de1f00c5 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -994,6 +994,7 @@
"DEVICE_RESET_ALL_DONE": "ℹ️ Все устройства сброшены",
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Не удалось сбросить устройства\n\nПопробуйте еще раз позже или обратитесь в техподдержку.\n\nВсего устройств: {total}",
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ Все устройства успешно сброшены!\n\n🔄 Сброшено: {count} устройств\n📱 Теперь вы можете заново подключить свои устройства\n\n💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения",
+ "DEVICE_RESET_COOLDOWN": "⏳ Сброс устройств доступен раз в {cooldown}. Попробуйте через {remaining}.",
"DEVICE_RESET_ERROR": "❌ Ошибка сброса устройства",
"DEVICE_RESET_ID_FAILED": "❌ Не удалось получить ID устройства",
"DEVICE_RESET_INVALID_REQUEST": "❌ Ошибка: некорректный запрос",
diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json
index 69139498..4deacb1b 100644
--- a/app/localization/locales/ua.json
+++ b/app/localization/locales/ua.json
@@ -927,6 +927,7 @@
"DEVICE_RESET_ALL_DONE": "ℹ️ Всі пристрої скинуто",
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Не вдалося скинути пристрої\n\nСпробуйте ще раз пізніше або зверніться до техпідтримки.\n\nВсього пристроїв: {total}",
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ Всі пристрої успішно скинуто!\n\n🔄 Скинуто: {count} пристроїв\n📱 Тепер ви можете заново підключити свої пристрої\n\n💡 Використовуйте посилання з розділу 'Моя підписка' для повторного підключення",
+ "DEVICE_RESET_COOLDOWN": "⏳ Скидання пристроїв доступне раз на {cooldown}. Спробуйте через {remaining}.",
"DEVICE_RESET_ERROR": "❌ Помилка скидання пристрою",
"DEVICE_RESET_ID_FAILED": "❌ Не вдалося отримати ID пристрою",
"DEVICE_RESET_INVALID_REQUEST": "❌ Помилка: некоректний запит",
diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json
index a107bf81..0444d118 100644
--- a/app/localization/locales/zh.json
+++ b/app/localization/locales/zh.json
@@ -926,6 +926,7 @@
"DEVICE_RESET_ALL_DONE":"ℹ️所有设备已重置",
"DEVICE_RESET_ALL_FAILED_MESSAGE":"❌重置设备失败\n\n请稍后再试或联系技术支持。\n\n总设备数:{total}",
"DEVICE_RESET_ALL_SUCCESS_MESSAGE":"✅所有设备已成功重置!\n\n🔄已重置:{count}台设备\n📱您现在可以重新连接您的设备\n\n💡使用“我的订阅”部分中的链接重新连接",
+"DEVICE_RESET_COOLDOWN":"⏳设备重置每隔{cooldown}可用一次。请在{remaining}后再试。",
"DEVICE_RESET_ERROR":"❌重置设备失败",
"DEVICE_RESET_ID_FAILED":"❌获取设备ID失败",
"DEVICE_RESET_INVALID_REQUEST":"❌错误:请求无效",
diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py
index c23cbda1..e654a29e 100644
--- a/app/services/remnawave_service.py
+++ b/app/services/remnawave_service.py
@@ -17,6 +17,7 @@ from sqlalchemy import and_, cast, delete, func, select, update, String
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
+from app.utils.happ_cryptolink_utils import generate_limited_happ_link
from app.database.crud.user import (
create_user_no_commit,
get_users_list,
@@ -237,6 +238,49 @@ class RemnaWaveService:
async with self.api as api:
yield api
+ async def _build_happ_crypto_link(
+ self,
+ api: RemnaWaveAPI,
+ subscription: "Subscription",
+ base_subscription_url: Optional[str],
+ panel_crypto_link: Optional[str],
+ ) -> Optional[str]:
+ if not settings.is_happ_cryptolink_mode():
+ return panel_crypto_link
+
+ if not settings.is_happ_cryptolink_limited_links_enabled():
+ return panel_crypto_link
+
+ provider_code, auth_key = settings.get_happ_cryptolink_credentials()
+ if not provider_code or not auth_key:
+ logger.debug("⚙️ Нет данных для генерации лимитированной Happ ссылки")
+ return panel_crypto_link
+
+ base_link = (base_subscription_url or "").strip()
+ if not base_link:
+ logger.warning("⚠️ Базовая ссылка подписки Happ отсутствует")
+ return panel_crypto_link
+
+ install_limit = settings.get_happ_cryptolink_install_limit(getattr(subscription, "device_limit", None))
+ if not install_limit:
+ logger.debug("⚙️ Лимит установок Happ не задан")
+ return panel_crypto_link
+
+ limited_link = await generate_limited_happ_link(
+ base_link,
+ settings.get_happ_cryptolink_add_install_url(),
+ provider_code,
+ auth_key,
+ install_limit,
+ )
+
+ if not limited_link:
+ logger.warning("⚠️ Не удалось создать лимитированную Happ ссылку")
+ return panel_crypto_link
+
+ encrypted_link = await api.encrypt_happ_crypto_link(limited_link)
+ return encrypted_link or limited_link
+
def _now_utc(self) -> datetime:
"""Возвращает текущее время в UTC без привязки к часовому поясу."""
return datetime.now(self._utc_timezone).replace(tzinfo=None)
@@ -2007,6 +2051,79 @@ class RemnaWaveService:
logger.error(f"Ошибка валидации данных пользователя: {e}")
return False
+ def get_devices_reset_cooldown_remaining(self, subscription: "Subscription") -> Optional[timedelta]:
+ if not settings.is_happ_cryptolink_mode() or not settings.is_happ_cryptolink_limited_links_enabled():
+ return None
+
+ cooldown = settings.get_happ_cryptolink_reset_cooldown()
+ if not cooldown:
+ return None
+
+ last_reset = getattr(subscription, "last_devices_reset_at", None)
+ if not last_reset:
+ return None
+
+ now = datetime.utcnow()
+ next_allowed = last_reset + cooldown
+ if now >= next_allowed:
+ return None
+
+ return next_allowed - now
+
+ async def refresh_happ_subscription_after_reset(
+ self,
+ db: AsyncSession,
+ user: "User",
+ ) -> Optional[str]:
+ if not settings.is_happ_cryptolink_mode() or not settings.is_happ_cryptolink_limited_links_enabled():
+ return None
+
+ if not getattr(user, "remnawave_uuid", None):
+ logger.debug("⚙️ Нет RemnaWave UUID для обновления Happ ссылки")
+ return None
+
+ subscription = getattr(user, "subscription", None)
+ if not subscription:
+ logger.debug("⚙️ У пользователя нет подписки для обновления Happ ссылки")
+ return None
+
+ try:
+ async with self.get_api_client() as api:
+ revoked_user = await api.revoke_user_subscription(user.remnawave_uuid)
+
+ subscription.remnawave_short_uuid = revoked_user.short_uuid
+ subscription.subscription_url = revoked_user.subscription_url
+
+ crypto_link = await self._build_happ_crypto_link(
+ api,
+ subscription,
+ revoked_user.subscription_url,
+ revoked_user.happ_crypto_link,
+ )
+
+ subscription.subscription_crypto_link = crypto_link or revoked_user.happ_crypto_link
+ subscription.last_devices_reset_at = datetime.utcnow()
+
+ await db.commit()
+
+ logger.info(
+ "🔄 Обновлена Happ ссылка после сброса устройств пользователя %s",
+ getattr(user, "telegram_id", "?"),
+ )
+
+ return subscription.subscription_crypto_link
+
+ except Exception as error:
+ logger.error(
+ "❌ Ошибка обновления Happ ссылки после сброса устройств: %s",
+ error,
+ )
+ try:
+ await db.rollback()
+ except Exception:
+ pass
+ return None
+
async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool:
try:
logger.info(f"🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user.telegram_id}")
diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py
index f7a29e2f..d325f651 100644
--- a/app/services/subscription_service.py
+++ b/app/services/subscription_service.py
@@ -20,6 +20,7 @@ from app.utils.pricing_utils import (
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
+from app.utils.happ_cryptolink_utils import generate_limited_happ_link
logger = logging.getLogger(__name__)
@@ -160,6 +161,49 @@ class SubscriptionService:
assert self.api is not None
async with self.api as api:
yield api
+
+ async def _build_happ_crypto_link(
+ self,
+ api: RemnaWaveAPI,
+ subscription: Subscription,
+ base_subscription_url: Optional[str],
+ panel_crypto_link: Optional[str],
+ ) -> Optional[str]:
+ if not settings.is_happ_cryptolink_mode():
+ return panel_crypto_link
+
+ if not settings.is_happ_cryptolink_limited_links_enabled():
+ return panel_crypto_link
+
+ provider_code, auth_key = settings.get_happ_cryptolink_credentials()
+ if not provider_code or not auth_key:
+ logger.debug("⚙️ Данные для лимитированных ссылок Happ не заданы")
+ return panel_crypto_link
+
+ base_link = (base_subscription_url or "").strip()
+ if not base_link:
+ logger.warning("⚠️ Базовая ссылка подписки для Happ отсутствует")
+ return panel_crypto_link
+
+ install_limit = settings.get_happ_cryptolink_install_limit(subscription.device_limit)
+ if not install_limit:
+ logger.debug("⚙️ Лимит установок для Happ не задан")
+ return panel_crypto_link
+
+ limited_link = await generate_limited_happ_link(
+ base_link,
+ settings.get_happ_cryptolink_add_install_url(),
+ provider_code,
+ auth_key,
+ install_limit,
+ )
+
+ if not limited_link:
+ logger.warning("⚠️ Не удалось сгенерировать лимитированную Happ ссылку")
+ return panel_crypto_link
+
+ encrypted_link = await api.encrypt_happ_crypto_link(limited_link)
+ return encrypted_link or limited_link
async def create_remnawave_user(
self,
@@ -266,7 +310,15 @@ class SubscriptionService:
subscription.remnawave_short_uuid = updated_user.short_uuid
subscription.subscription_url = updated_user.subscription_url
- subscription.subscription_crypto_link = updated_user.happ_crypto_link
+
+ crypto_link = await self._build_happ_crypto_link(
+ api,
+ subscription,
+ updated_user.subscription_url,
+ updated_user.happ_crypto_link,
+ )
+
+ subscription.subscription_crypto_link = crypto_link or updated_user.happ_crypto_link
user.remnawave_uuid = updated_user.uuid
await db.commit()
@@ -348,7 +400,15 @@ class SubscriptionService:
)
subscription.subscription_url = updated_user.subscription_url
- subscription.subscription_crypto_link = updated_user.happ_crypto_link
+
+ crypto_link = await self._build_happ_crypto_link(
+ api,
+ subscription,
+ updated_user.subscription_url,
+ updated_user.happ_crypto_link,
+ )
+
+ subscription.subscription_crypto_link = crypto_link or updated_user.happ_crypto_link
await db.commit()
status_text = "активным" if is_actually_active else "истёкшим"
diff --git a/app/utils/happ_cryptolink_utils.py b/app/utils/happ_cryptolink_utils.py
new file mode 100644
index 00000000..d808fa7d
--- /dev/null
+++ b/app/utils/happ_cryptolink_utils.py
@@ -0,0 +1,80 @@
+from typing import Optional
+from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
+
+import aiohttp
+
+
+def _append_query_param_to_fragment(fragment: str, param: str, value: str) -> str:
+ base_part, _, query_part = fragment.partition("?")
+ query_params = dict(parse_qsl(query_part, keep_blank_values=True))
+ query_params[param] = value
+
+ encoded_query = urlencode(query_params)
+ return f"{base_part}?{encoded_query}" if base_part else encoded_query
+
+
+def append_install_code(base_link: str, install_code: str, param_name: str = "installid") -> str:
+ """Добавляет параметр installid в ссылку, сохраняя существующие параметры."""
+
+ if not install_code:
+ return base_link
+
+ parsed = urlsplit(base_link)
+
+ if parsed.fragment:
+ updated_fragment = _append_query_param_to_fragment(parsed.fragment, param_name, install_code)
+ return urlunsplit((
+ parsed.scheme,
+ parsed.netloc,
+ parsed.path,
+ parsed.query,
+ updated_fragment,
+ ))
+
+ query_params = dict(parse_qsl(parsed.query, keep_blank_values=True))
+ query_params[param_name] = install_code
+ updated_query = urlencode(query_params)
+
+ return urlunsplit((
+ parsed.scheme,
+ parsed.netloc,
+ parsed.path,
+ updated_query,
+ parsed.fragment,
+ ))
+
+
+async def generate_limited_happ_link(
+ base_link: str,
+ api_url: str,
+ provider_code: str,
+ auth_key: str,
+ install_limit: int,
+ *,
+ timeout_seconds: int = 15,
+) -> Optional[str]:
+ if not base_link or install_limit <= 0:
+ return None
+
+ timeout = aiohttp.ClientTimeout(total=timeout_seconds)
+ params = {
+ "provider_code": provider_code,
+ "auth_key": auth_key,
+ "install_limit": install_limit,
+ }
+
+ try:
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.get(api_url, params=params) as response:
+ data = await response.json()
+ except Exception:
+ return None
+
+ if data.get("rc") != 1:
+ return None
+
+ install_code = data.get("install_code") or data.get("installCode")
+ if not install_code:
+ return None
+
+ return append_install_code(base_link, install_code)
diff --git a/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py b/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py
new file mode 100644
index 00000000..262dd488
--- /dev/null
+++ b/migrations/alembic/versions/f6c5c60dba2e_add_devices_reset_timestamp.py
@@ -0,0 +1,21 @@
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "f6c5c60dba2e"
+down_revision: Union[str, None] = "e3c1e0b5b4a7"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "subscriptions",
+ sa.Column("last_devices_reset_at", sa.DateTime(), nullable=True),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("subscriptions", "last_devices_reset_at")