mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-19 19:01:12 +00:00
Add cooldown for Happ device reset links
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -982,6 +982,7 @@
|
||||
"DEVICE_RESET_ALL_DONE": "ℹ️ All devices have been reset",
|
||||
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Couldn't reset devices</b>\n\nPlease try again later or contact support.\n\nTotal devices: {total}",
|
||||
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>All devices have been reset!</b>\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",
|
||||
|
||||
@@ -994,6 +994,7 @@
|
||||
"DEVICE_RESET_ALL_DONE": "ℹ️ Все устройства сброшены",
|
||||
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Не удалось сбросить устройства</b>\n\nПопробуйте еще раз позже или обратитесь в техподдержку.\n\nВсего устройств: {total}",
|
||||
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>Все устройства успешно сброшены!</b>\n\n🔄 Сброшено: {count} устройств\n📱 Теперь вы можете заново подключить свои устройства\n\n💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения",
|
||||
"DEVICE_RESET_COOLDOWN": "⏳ Сброс устройств доступен раз в {cooldown}. Попробуйте через {remaining}.",
|
||||
"DEVICE_RESET_ERROR": "❌ Ошибка сброса устройства",
|
||||
"DEVICE_RESET_ID_FAILED": "❌ Не удалось получить ID устройства",
|
||||
"DEVICE_RESET_INVALID_REQUEST": "❌ Ошибка: некорректный запрос",
|
||||
|
||||
@@ -927,6 +927,7 @@
|
||||
"DEVICE_RESET_ALL_DONE": "ℹ️ Всі пристрої скинуто",
|
||||
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Не вдалося скинути пристрої</b>\n\nСпробуйте ще раз пізніше або зверніться до техпідтримки.\n\nВсього пристроїв: {total}",
|
||||
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>Всі пристрої успішно скинуто!</b>\n\n🔄 Скинуто: {count} пристроїв\n📱 Тепер ви можете заново підключити свої пристрої\n\n💡 Використовуйте посилання з розділу 'Моя підписка' для повторного підключення",
|
||||
"DEVICE_RESET_COOLDOWN": "⏳ Скидання пристроїв доступне раз на {cooldown}. Спробуйте через {remaining}.",
|
||||
"DEVICE_RESET_ERROR": "❌ Помилка скидання пристрою",
|
||||
"DEVICE_RESET_ID_FAILED": "❌ Не вдалося отримати ID пристрою",
|
||||
"DEVICE_RESET_INVALID_REQUEST": "❌ Помилка: некоректний запит",
|
||||
|
||||
@@ -926,6 +926,7 @@
|
||||
"DEVICE_RESET_ALL_DONE":"ℹ️所有设备已重置",
|
||||
"DEVICE_RESET_ALL_FAILED_MESSAGE":"❌<b>重置设备失败</b>\n\n请稍后再试或联系技术支持。\n\n总设备数:{total}",
|
||||
"DEVICE_RESET_ALL_SUCCESS_MESSAGE":"✅<b>所有设备已成功重置!</b>\n\n🔄已重置:{count}台设备\n📱您现在可以重新连接您的设备\n\n💡使用“我的订阅”部分中的链接重新连接",
|
||||
"DEVICE_RESET_COOLDOWN":"⏳设备重置每隔{cooldown}可用一次。请在{remaining}后再试。",
|
||||
"DEVICE_RESET_ERROR":"❌重置设备失败",
|
||||
"DEVICE_RESET_ID_FAILED":"❌获取设备ID失败",
|
||||
"DEVICE_RESET_INVALID_REQUEST":"❌错误:请求无效",
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 "истёкшим"
|
||||
|
||||
80
app/utils/happ_cryptolink_utils.py
Normal file
80
app/utils/happ_cryptolink_utils.py
Normal file
@@ -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)
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user