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