diff --git a/.env.example b/.env.example index 1070f38a..e405f914 100644 --- a/.env.example +++ b/.env.example @@ -291,6 +291,8 @@ CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false HAPP_DOWNLOAD_LINK_IOS= HAPP_DOWNLOAD_LINK_ANDROID= HAPP_DOWNLOAD_LINK_PC= +HAPP_CRYPTOLINK_PROXY_BASE_URL= +HAPP_CRYPTOLINK_PROXY_PATH=/happ-link # Пропустить принятие правил использования бота SKIP_RULES_ACCEPT=false diff --git a/app/config.py b/app/config.py index bace5055..2d61952f 100644 --- a/app/config.py +++ b/app/config.py @@ -210,6 +210,8 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False + HAPP_CRYPTOLINK_PROXY_BASE_URL: Optional[str] = None + HAPP_CRYPTOLINK_PROXY_PATH: str = "/happ-link" HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None HAPP_DOWNLOAD_LINK_PC: Optional[str] = None @@ -547,9 +549,26 @@ class Settings(BaseSettings): def get_cryptobot_invoice_expires_seconds(self) -> int: return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600 + def get_happ_cryptolink_proxy_base_url(self) -> Optional[str]: + base_url = (self.HAPP_CRYPTOLINK_PROXY_BASE_URL or self.WEBHOOK_URL or "").strip() + if not base_url: + return None + return base_url.rstrip('/') + + def get_happ_cryptolink_proxy_path(self) -> str: + path = (self.HAPP_CRYPTOLINK_PROXY_PATH or "").strip() + if not path: + path = "/happ-link" + if not path.startswith('/'): + path = f"/{path}" + return path + def is_happ_cryptolink_mode(self) -> bool: return self.CONNECT_BUTTON_MODE == "happ_cryptolink" + def is_happ_cryptolink_proxy_enabled(self) -> bool: + return self.is_happ_cryptolink_mode() and self.get_happ_cryptolink_proxy_base_url() is not None + def is_happ_download_button_enabled(self) -> bool: return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 9ea90945..7cef933d 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -8,6 +8,11 @@ from aiohttp import web from aiogram import Bot from app.config import settings +from app.utils.happ_links import ( + HAPP_LINK_QUERY_PARAM, + decode_happ_link, + render_happ_redirect_page, +) from app.services.tribute_service import TributeService from app.services.payment_service import PaymentService from app.database.database import get_db @@ -35,9 +40,15 @@ class WebhookServer: if settings.is_cryptobot_enabled(): self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler) - + self.app.router.add_get('/health', self._health_check) - + + if settings.is_happ_cryptolink_proxy_enabled(): + proxy_path = settings.get_happ_cryptolink_proxy_path() + self.app.router.add_get(proxy_path, self._happ_cryptolink_handler) + self.app.router.add_head(proxy_path, self._happ_cryptolink_handler) + logger.info(f" - Happ cryptoLink proxy: GET {proxy_path}") + self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler) if settings.is_mulenpay_enabled(): self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler) @@ -50,8 +61,10 @@ class WebhookServer: logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}") if settings.is_cryptobot_enabled(): logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}") + if settings.is_happ_cryptolink_proxy_enabled(): + logger.info(f" - Happ cryptoLink proxy: GET {settings.get_happ_cryptolink_proxy_path()}") logger.info(f" - Health check: GET /health") - + return self.app async def start(self): @@ -177,6 +190,19 @@ class WebhookServer: logger.error("Отсутствует подпись Mulen Pay webhook") return False + async def _happ_cryptolink_handler(self, request: web.Request) -> web.Response: + if request.method == 'HEAD': + return web.Response(status=200) + + token = request.query.get(HAPP_LINK_QUERY_PARAM, "") + happ_link = decode_happ_link(token) + if not happ_link: + logger.warning("Получен некорректный запрос к Happ proxy: %s", request.query_string) + return web.Response(status=400, text="Invalid or missing link") + + html_page = render_happ_redirect_page(happ_link) + return web.Response(text=html_page, content_type='text/html; charset=utf-8') + async def _tribute_webhook_handler(self, request: web.Request) -> web.Response: try: diff --git a/app/utils/happ_links.py b/app/utils/happ_links.py new file mode 100644 index 00000000..75910368 --- /dev/null +++ b/app/utils/happ_links.py @@ -0,0 +1,133 @@ +import base64 +import html +import logging +from typing import Optional + +from app.config import settings + +logger = logging.getLogger(__name__) + +HAPP_LINK_QUERY_PARAM = "data" +_HAPP_SCHEME_PREFIX = "happ://" + + +def _encode_happ_link(link: str) -> str: + encoded = base64.urlsafe_b64encode(link.encode("utf-8")).decode("ascii") + return encoded.rstrip("=") + + +def decode_happ_link(token: str) -> Optional[str]: + if not token: + return None + + padding = "=" * (-len(token) % 4) + try: + decoded = base64.urlsafe_b64decode(f"{token}{padding}".encode("ascii")).decode("utf-8") + except (ValueError, UnicodeDecodeError): + logger.warning("Не удалось декодировать cryptoLink из токена") + return None + + if not decoded.startswith(_HAPP_SCHEME_PREFIX): + logger.warning("Попытка открыть ссылку с неподдерживаемым протоколом: %s", decoded) + return None + + return decoded + + +def build_happ_proxy_link(crypto_link: str) -> Optional[str]: + base_url = settings.get_happ_cryptolink_proxy_base_url() + if not base_url: + logger.error("Не задан базовый URL для прокси Happ cryptoLink") + return None + + path = settings.get_happ_cryptolink_proxy_path() + token = _encode_happ_link(crypto_link) + return f"{base_url}{path}?{HAPP_LINK_QUERY_PARAM}={token}" + + +def render_happ_redirect_page(happ_link: str) -> str: + escaped_link = html.escape(happ_link, quote=True) + return f""" + + + + + Открытие Happ + + + + +
+

Подключение Happ

+

Если приложение Happ не открылось автоматически, нажмите кнопку ниже или скопируйте ссылку вручную.

+ Открыть в Happ +

Ссылка для копирования:
{escaped_link}

+
+ + +""" diff --git a/app/utils/subscription_utils.py b/app/utils/subscription_utils.py index 62db4142..8e30dcb8 100644 --- a/app/utils/subscription_utils.py +++ b/app/utils/subscription_utils.py @@ -5,6 +5,7 @@ from sqlalchemy import select, delete, func from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import Subscription, User from app.config import settings +from app.utils.happ_links import build_happ_proxy_link logger = logging.getLogger(__name__) @@ -106,6 +107,11 @@ def get_display_subscription_link(subscription: Optional[Subscription]) -> Optio if settings.is_happ_cryptolink_mode(): crypto_link = getattr(subscription, "subscription_crypto_link", None) - return crypto_link or base_link + if crypto_link: + proxy_link = build_happ_proxy_link(crypto_link) + if proxy_link: + return proxy_link + logger.warning("Не удалось сформировать прокси-ссылку для Happ, возвращаем стандартную ссылку") + return base_link return base_link diff --git a/main.py b/main.py index 254e5c5d..99a7be23 100644 --- a/main.py +++ b/main.py @@ -126,6 +126,7 @@ async def main(): settings.TRIBUTE_ENABLED or settings.is_cryptobot_enabled() or settings.is_mulenpay_enabled() + or settings.is_happ_cryptolink_proxy_enabled() ) if webhook_needed: @@ -136,7 +137,9 @@ async def main(): enabled_services.append("Mulen Pay") if settings.is_cryptobot_enabled(): enabled_services.append("CryptoBot") - + if settings.is_happ_cryptolink_proxy_enabled(): + enabled_services.append("Happ cryptoLink proxy") + logger.info(f"🌐 Запуск webhook сервера для: {', '.join(enabled_services)}...") webhook_server = WebhookServer(bot) await webhook_server.start()