From ce0d4dccf7179a1d71b850a1fa7380c76045f58a Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Sat, 23 May 2026 09:48:30 +0300
Subject: [PATCH] feat: open bot install guides in mini app
---
.../bot/app/web/admin_settings_manifest.py | 10 +++
backend/bot/handlers/user/promo_user.py | 22 +++++
backend/bot/handlers/user/start.py | 22 +++++
.../bot/handlers/user/subscription/core.py | 44 +++++++++-
backend/bot/handlers/user/trial_handler.py | 23 +++++
.../bot/keyboards/inline/user_keyboards.py | 19 ++++-
.../bot/payment_providers/shared/success.py | 32 +++++++
backend/bot/payment_providers/yookassa.py | 15 ++++
backend/bot/utils/install_links.py | 84 +++++++++++++++++++
backend/bot/utils/mini_app_url.py | 31 ++++++-
backend/config/settings.py | 7 ++
locales/en.json | 4 +
locales/ru.json | 4 +
tests/test_admin_settings_manifest_i18n.py | 1 +
tests/test_mini_app_url.py | 38 ++++++++-
tests/test_settings.py | 1 +
tests/test_user_bot_menu.py | 40 +++++++++
17 files changed, 393 insertions(+), 4 deletions(-)
create mode 100644 backend/bot/utils/install_links.py
diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py
index 1ede0fa..fc7c0c6 100644
--- a/backend/bot/app/web/admin_settings_manifest.py
+++ b/backend/bot/app/web/admin_settings_manifest.py
@@ -167,6 +167,16 @@ SETTINGS_MANIFEST: List[SettingField] = [
"Embedded install guides",
"Open install instructions inside the Web App instead of an external connect page.",
),
+ SettingField(
+ "SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED",
+ "bool",
+ "subscription_guides",
+ "Open install guides from bot",
+ (
+ "Use the Telegram Mini App install screen for bot connect buttons and show "
+ "public install guide links."
+ ),
+ ),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
"bool",
diff --git a/backend/bot/handlers/user/promo_user.py b/backend/bot/handlers/user/promo_user.py
index 2996bee..973e1eb 100644
--- a/backend/bot/handlers/user/promo_user.py
+++ b/backend/bot/handlers/user/promo_user.py
@@ -16,6 +16,10 @@ from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService
from bot.states.user_states import UserPromoStates
from bot.utils.callback_answer import safe_answer_callback
+from bot.utils.install_links import (
+ append_install_share_link_text,
+ ensure_user_install_guide_links,
+)
from config.settings import Settings
from .start import send_main_menu
@@ -160,12 +164,30 @@ async def process_promo_code_input(
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link_text,
)
+ install_links = await ensure_user_install_guide_links(session, settings, user.id)
+ install_share_url = install_links.public_share_url
+ if install_share_url:
+ try:
+ await session.commit()
+ response_to_user_text = append_install_share_link_text(
+ response_to_user_text,
+ _,
+ install_share_url,
+ )
+ except Exception:
+ await session.rollback()
+ logging.exception(
+ "Failed to persist install guide share token for promo user %s.",
+ user.id,
+ )
+ install_share_url = None
reply_markup = get_connect_and_main_keyboard(
current_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
+ install_share_url=install_share_url,
)
else:
await session.commit()
diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py
index 4233a07..981fb73 100644
--- a/backend/bot/handlers/user/start.py
+++ b/backend/bot/handlers/user/start.py
@@ -23,6 +23,10 @@ from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.callback_answer import safe_answer_callback
+from bot.utils.install_links import (
+ append_install_share_link_text,
+ ensure_user_install_guide_links,
+)
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import user_dal
@@ -715,6 +719,23 @@ async def start_command_handler(
),
config_link=config_link_text,
)
+ install_links = await ensure_user_install_guide_links(session, settings, user_id)
+ install_share_url = install_links.public_share_url
+ if install_share_url:
+ try:
+ await session.commit()
+ promo_success_text = append_install_share_link_text(
+ promo_success_text,
+ _,
+ install_share_url,
+ )
+ except Exception:
+ await session.rollback()
+ logging.exception(
+ "Failed to persist install guide share token for promo user %s.",
+ user_id,
+ )
+ install_share_url = None
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
@@ -726,6 +747,7 @@ async def start_command_handler(
settings,
config_link_display,
connect_button_url=connect_button_url,
+ install_share_url=install_share_url,
),
parse_mode="HTML",
)
diff --git a/backend/bot/handlers/user/subscription/core.py b/backend/bot/handlers/user/subscription/core.py
index 76327eb..d39278b 100644
--- a/backend/bot/handlers/user/subscription/core.py
+++ b/backend/bot/handlers/user/subscription/core.py
@@ -27,6 +27,10 @@ from bot.keyboards.inline.user_keyboards import (
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
+from bot.utils.install_links import (
+ append_install_share_link_text,
+ ensure_user_install_guide_links,
+)
from config.settings import Settings
from db.dal import subscription_dal, user_billing_dal
from db.models import Subscription
@@ -1026,12 +1030,50 @@ async def my_subscription_command_handler(
local_sub = await subscription_dal.get_active_subscription_by_user_id(
session, event.from_user.id
)
+ install_links = await ensure_user_install_guide_links(
+ session,
+ settings,
+ event.from_user.id,
+ local_subscription=local_sub,
+ )
+ install_url = install_links.personal_url
+ install_share_url = install_links.public_share_url
+ if install_share_url:
+ try:
+ await session.commit()
+ text = append_install_share_link_text(text, get_text, install_share_url)
+ except Exception:
+ await session.rollback()
+ logging.exception(
+ "Failed to persist install guide share token for user %s.",
+ event.from_user.id,
+ )
+ install_share_url = None
+
# Build rows to prepend above the base "back" markup
prepend_rows = []
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app
cfg_link_val = connect_button_url or config_link_display
- if cfg_link_val:
+ if install_url:
+ prepend_rows.append(
+ [
+ InlineKeyboardButton(
+ text=get_text("connect_button"),
+ web_app=WebAppInfo(url=install_url),
+ )
+ ]
+ )
+ if install_share_url:
+ prepend_rows.append(
+ [
+ InlineKeyboardButton(
+ text=get_text("install_guide_share_button"),
+ url=install_share_url,
+ )
+ ]
+ )
+ elif cfg_link_val:
prepend_rows.append(
[
InlineKeyboardButton(
diff --git a/backend/bot/handlers/user/trial_handler.py b/backend/bot/handlers/user/trial_handler.py
index 078080f..843675f 100644
--- a/backend/bot/handlers/user/trial_handler.py
+++ b/backend/bot/handlers/user/trial_handler.py
@@ -14,6 +14,10 @@ from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
+from bot.utils.install_links import (
+ append_install_share_link_text,
+ ensure_user_install_guide_links,
+)
from config.settings import Settings
from .start import send_main_menu
@@ -74,6 +78,7 @@ async def request_trial_confirmation_handler(
config_link_display_for_trial = None
config_link_for_trial = None
connect_button_url_for_trial = None
+ install_share_url = None
if activation_result and activation_result.get("activated"):
try:
@@ -104,6 +109,14 @@ async def request_trial_confirmation_handler(
traffic_gb=traffic_display,
)
+ install_links = await ensure_user_install_guide_links(session, settings, user_id)
+ install_share_url = install_links.public_share_url
+ final_message_text_in_chat = append_install_share_link_text(
+ final_message_text_in_chat,
+ _,
+ install_share_url,
+ )
+
# Send notification to admin about new trial
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
@@ -139,6 +152,7 @@ async def request_trial_confirmation_handler(
settings,
config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial,
+ install_share_url=install_share_url,
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
@@ -214,6 +228,7 @@ async def confirm_activate_trial_handler(
config_link_display_for_trial = None
config_link_for_trial = None
connect_button_url_for_trial = None
+ install_share_url = None
if activation_result and activation_result.get("activated"):
try:
@@ -243,6 +258,13 @@ async def confirm_activate_trial_handler(
config_link=config_link_for_trial,
traffic_gb=traffic_display,
)
+ install_links = await ensure_user_install_guide_links(session, settings, user_id)
+ install_share_url = install_links.public_share_url
+ final_message_text_in_chat = append_install_share_link_text(
+ final_message_text_in_chat,
+ _,
+ install_share_url,
+ )
else:
message_key_from_service = (
activation_result.get("message_key", "trial_activation_failed")
@@ -266,6 +288,7 @@ async def confirm_activate_trial_handler(
settings,
config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial,
+ install_share_url=install_share_url,
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py
index ae2ea06..035eb20 100644
--- a/backend/bot/keyboards/inline/user_keyboards.py
+++ b/backend/bot/keyboards/inline/user_keyboards.py
@@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional, Tuple
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
+from bot.utils.install_links import bot_install_guide_url
from config.settings import Settings
BOT_MENU_CONTEXT = "bot"
@@ -689,13 +690,29 @@ def get_connect_and_main_keyboard(
config_link: Optional[str],
connect_button_url: Optional[str] = None,
preserve_message: bool = False,
+ install_share_url: Optional[str] = None,
) -> InlineKeyboardMarkup:
"""Keyboard with a connect button and a back to main menu button."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
+ install_url = bot_install_guide_url(settings)
button_target = connect_button_url or config_link
- if button_target:
+ if install_url:
+ builder.row(
+ InlineKeyboardButton(
+ text=_("connect_button"),
+ web_app=WebAppInfo(url=install_url),
+ )
+ )
+ if install_share_url:
+ builder.row(
+ InlineKeyboardButton(
+ text=_("install_guide_share_button"),
+ url=install_share_url,
+ )
+ )
+ elif button_target:
builder.row(InlineKeyboardButton(text=_("connect_button"), url=button_target))
elif settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
diff --git a/backend/bot/payment_providers/shared/success.py b/backend/bot/payment_providers/shared/success.py
index 0a09b58..c47c72d 100644
--- a/backend/bot/payment_providers/shared/success.py
+++ b/backend/bot/payment_providers/shared/success.py
@@ -11,6 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import NotificationService
from bot.utils.config_link import prepare_config_links
+from bot.utils.install_links import (
+ append_install_share_link_text,
+ ensure_user_install_guide_links,
+)
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from db.dal import payment_dal, user_dal
from db.models import Payment, User
@@ -136,6 +140,7 @@ async def send_success_message_to_user(
settings: Any,
config_link_display: Optional[str],
connect_button_url: Optional[str],
+ install_share_url: Optional[str] = None,
include_keyboard: bool = True,
log_prefix: str = "payment_providers",
) -> None:
@@ -148,6 +153,7 @@ async def send_success_message_to_user(
settings,
config_link_display,
connect_button_url=connect_button_url,
+ install_share_url=install_share_url,
preserve_message=True,
)
try:
@@ -332,6 +338,31 @@ async def finalize_successful_payment(
if req.text_prefix:
success_text = f"{req.text_prefix}\n{success_text}"
+ install_share_url = None
+ if not req.skip_keyboard:
+ install_links = await ensure_user_install_guide_links(
+ req.session,
+ req.settings,
+ req.user_id,
+ )
+ install_share_url = install_links.public_share_url
+ if install_share_url:
+ try:
+ await req.session.commit()
+ success_text = append_install_share_link_text(
+ success_text,
+ translator,
+ install_share_url,
+ )
+ except Exception:
+ await req.session.rollback()
+ logging.exception(
+ "%s: failed to persist install guide share token for user %s.",
+ req.log_prefix,
+ req.user_id,
+ )
+ install_share_url = None
+
await send_success_message_to_user(
bot=req.bot,
user_id=req.user_id,
@@ -341,6 +372,7 @@ async def finalize_successful_payment(
settings=req.settings,
config_link_display=config_link_display,
connect_button_url=connect_button_url,
+ install_share_url=install_share_url,
include_keyboard=not req.skip_keyboard,
log_prefix=req.log_prefix,
)
diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py
index 3f82b9e..719ff6d 100644
--- a/backend/bot/payment_providers/yookassa.py
+++ b/backend/bot/payment_providers/yookassa.py
@@ -36,6 +36,10 @@ from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
+from bot.utils.install_links import (
+ append_install_share_link_text,
+ ensure_user_install_guide_links,
+)
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from db.dal import payment_dal, user_billing_dal, user_dal
@@ -741,6 +745,16 @@ async def process_successful_payment(
)
)
include_keyboard = True
+
+ install_share_url = None
+ if include_keyboard:
+ install_links = await ensure_user_install_guide_links(session, settings, user_id)
+ install_share_url = install_links.public_share_url
+ details_message = append_install_share_link_text(
+ details_message,
+ translator,
+ install_share_url,
+ )
await send_success_message_to_user(
bot=bot,
user_id=user_id,
@@ -750,6 +764,7 @@ async def process_successful_payment(
settings=settings,
config_link_display=config_link_display,
connect_button_url=connect_button_url,
+ install_share_url=install_share_url,
include_keyboard=include_keyboard,
log_prefix="YooKassa webhook",
)
diff --git a/backend/bot/utils/install_links.py b/backend/bot/utils/install_links.py
new file mode 100644
index 0000000..1089c70
--- /dev/null
+++ b/backend/bot/utils/install_links.py
@@ -0,0 +1,84 @@
+"""Helpers for Telegram bot install-guide links."""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Any, Optional
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from bot.utils.mini_app_url import (
+ subscription_mini_app_install_url,
+ subscription_public_install_url,
+)
+from config.subscription_guides_config import subscription_guides_available
+from db.dal import subscription_dal
+
+
+@dataclass(frozen=True)
+class InstallGuideLinks:
+ personal_url: Optional[str] = None
+ public_share_url: Optional[str] = None
+
+
+def bot_install_guides_enabled(settings: Any) -> bool:
+ return bool(
+ getattr(settings, "SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED", False)
+ and subscription_guides_available(settings)
+ and subscription_mini_app_install_url(settings)
+ )
+
+
+def bot_install_guide_url(settings: Any) -> Optional[str]:
+ if not bot_install_guides_enabled(settings):
+ return None
+ return subscription_mini_app_install_url(settings)
+
+
+async def ensure_user_install_guide_links(
+ session: AsyncSession,
+ settings: Any,
+ user_id: int,
+ panel_user_uuid: Optional[str] = None,
+ local_subscription: Optional[Any] = None,
+) -> InstallGuideLinks:
+ personal_url = bot_install_guide_url(settings)
+ if not personal_url:
+ return InstallGuideLinks()
+
+ public_share_url = None
+ try:
+ local_sub = (
+ local_subscription
+ if local_subscription is not None
+ else await subscription_dal.get_active_subscription_by_user_id(
+ session,
+ user_id,
+ panel_user_uuid,
+ )
+ )
+ if local_sub is not None:
+ share_token = await subscription_dal.ensure_install_share_token(session, local_sub)
+ public_share_url = subscription_public_install_url(settings, share_token)
+ except Exception:
+ logging.exception("Failed to resolve install guide share link for user %s.", user_id)
+
+ return InstallGuideLinks(personal_url=personal_url, public_share_url=public_share_url)
+
+
+def append_install_share_link_text(
+ text: str,
+ translator: Any,
+ public_share_url: Optional[str],
+) -> str:
+ if not public_share_url:
+ return text
+ try:
+ share_line = translator(
+ "install_guide_share_link_line",
+ install_share_link=public_share_url,
+ )
+ except Exception:
+ share_line = f"\n\nInstall guide:\n{public_share_url}"
+ return f"{text}{share_line}"
diff --git a/backend/bot/utils/mini_app_url.py b/backend/bot/utils/mini_app_url.py
index 3dbea92..904001e 100644
--- a/backend/bot/utils/mini_app_url.py
+++ b/backend/bot/utils/mini_app_url.py
@@ -3,9 +3,10 @@
from __future__ import annotations
from typing import Optional
-from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
+from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
from config.settings import Settings
+from db.dal.subscription_dal import normalize_install_share_token
def append_query_params(base_url: str, params: dict[str, str]) -> str:
@@ -31,3 +32,31 @@ def subscription_mini_app_topup_url(settings: Settings, kind: str) -> Optional[s
return None
normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular"
return append_query_params(base, {"topup": normalized})
+
+
+def subscription_mini_app_path_url(settings: Settings, path: str) -> Optional[str]:
+ """Return a Mini App URL with ``path`` appended to the configured app base."""
+ base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
+ if not base:
+ return None
+ normalized_path = f"/{str(path or '').lstrip('/')}"
+ return f"{base.rstrip('/')}{normalized_path}"
+
+
+def subscription_mini_app_install_url(settings: Settings) -> Optional[str]:
+ """Return the personal embedded install guide URL."""
+ return subscription_mini_app_path_url(settings, "/install")
+
+
+def subscription_public_install_url(settings: Settings, share_token: str) -> Optional[str]:
+ """Return the public install guide URL for a normalized share token."""
+ token = normalize_install_share_token(share_token)
+ base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
+ if not token or not base:
+ return None
+ parts = urlsplit(base)
+ if parts.scheme and parts.netloc:
+ public_base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
+ else:
+ public_base = base.rstrip("/")
+ return f"{public_base.rstrip('/')}/s/{quote(token)}"
diff --git a/backend/config/settings.py b/backend/config/settings.py
index d003055..6bf2a76 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -327,6 +327,13 @@ class Settings(BaseSettings):
default=True,
description="Show embedded install instructions inside the subscription Mini App.",
)
+ SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED: bool = Field(
+ default=False,
+ description=(
+ "Open Mini App install guides from Telegram bot connect buttons and show public "
+ "install guide share links."
+ ),
+ )
SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED: bool = Field(
default=True,
description="Use Remnawave Panel Subscription Page config for embedded guides when available.",
diff --git a/locales/en.json b/locales/en.json
index 48c154c..4f49d1b 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -50,6 +50,8 @@
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
"back_to_payment_methods_button": "⬅️ Back",
"connect_button": "🔗 Connect",
+ "install_guide_share_button": "🔗 Share install guide",
+ "install_guide_share_link_line": "\n\nInstall guide for sharing:\n{install_share_link}",
"cancel_button": "❌ Cancel",
"devices_button": "📱 My Devices ({current_devices}/{max_devices})",
"my_devices_details": "📱 My Devices ({current_devices}/{max_devices})\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n
Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.", @@ -1618,6 +1620,8 @@ "admin_settings_section_subscription_guides": "Install guides", "admin_settings_field_subscription_guides_enabled_label": "Embedded install guides", "admin_settings_field_subscription_guides_enabled_description": "Open install instructions inside the Web App instead of an external connect page.", + "admin_settings_field_subscription_guides_bot_menu_enabled_label": "Open install guides from bot", + "admin_settings_field_subscription_guides_bot_menu_enabled_description": "Use the Telegram Mini App install screen for bot connect buttons and show public install guide links.", "admin_settings_field_subscription_page_config_panel_enabled_label": "Use Remnawave Panel config", "admin_settings_field_subscription_page_config_panel_enabled_description": "Fetch Subscription Page config from Remnawave Panel by the user's subscription short UUID.", "admin_settings_field_subscription_page_config_json_override_enabled_label": "Enable admin JSON override", diff --git a/locales/ru.json b/locales/ru.json index f77c31f..aa2014d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -50,6 +50,8 @@ "yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.", "back_to_payment_methods_button": "⬅️ Назад", "connect_button": "🔗 Подключиться", + "install_guide_share_button": "🔗 Поделиться инструкцией", + "install_guide_share_link_line": "\n\nИнструкция для передачи:\n
{install_share_link}",
"devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
"my_devices_details": "📱 Список ваших устройств ({current_devices}/{max_devices})\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\nПримечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.", "no_devices_details_found_message": "📱 Список ваших устройств\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.", @@ -1618,6 +1620,8 @@ "admin_settings_section_subscription_guides": "Инструкции подключения", "admin_settings_field_subscription_guides_enabled_label": "Встроенные инструкции подключения", "admin_settings_field_subscription_guides_enabled_description": "Открывать инструкции прямо внутри Web App вместо внешней страницы подключения.", + "admin_settings_field_subscription_guides_bot_menu_enabled_label": "Открывать инструкции из бота", + "admin_settings_field_subscription_guides_bot_menu_enabled_description": "Открывать экран установки в Telegram Mini App для кнопок подключения в боте и показывать публичные ссылки на инструкцию.", "admin_settings_field_subscription_page_config_panel_enabled_label": "Использовать конфиг Remnawave Panel", "admin_settings_field_subscription_page_config_panel_enabled_description": "Брать Subscription Page config из Remnawave Panel по short UUID подписки пользователя.", "admin_settings_field_subscription_page_config_json_override_enabled_label": "Включить JSON-override из админки", diff --git a/tests/test_admin_settings_manifest_i18n.py b/tests/test_admin_settings_manifest_i18n.py index 39d55e0..2232ac7 100644 --- a/tests/test_admin_settings_manifest_i18n.py +++ b/tests/test_admin_settings_manifest_i18n.py @@ -24,6 +24,7 @@ SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS = ( SUBSCRIPTION_GUIDE_SETTINGS = ( "SUBSCRIPTION_GUIDES_ENABLED", + "SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED", "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", "SUBSCRIPTION_PAGE_CONFIG_PATH", diff --git a/tests/test_mini_app_url.py b/tests/test_mini_app_url.py index 29109ff..1f551f4 100644 --- a/tests/test_mini_app_url.py +++ b/tests/test_mini_app_url.py @@ -1,6 +1,12 @@ import unittest -from bot.utils.mini_app_url import append_query_params, subscription_mini_app_topup_url +from bot.utils.mini_app_url import ( + append_query_params, + subscription_mini_app_install_url, + subscription_mini_app_path_url, + subscription_mini_app_topup_url, + subscription_public_install_url, +) from config.settings import Settings @@ -39,3 +45,33 @@ class MiniAppUrlTests(unittest.TestCase): subscription_mini_app_topup_url(s, "regular"), "https://app.example.com/webapp?topup=regular", ) + + def test_subscription_mini_app_path_url(self): + s = Settings( + _env_file=None, + BOT_TOKEN="x", + POSTGRES_USER="u", + POSTGRES_PASSWORD="p", + SUBSCRIPTION_MINI_APP_URL="https://app.example.com/webapp/", + ) + self.assertEqual( + subscription_mini_app_path_url(s, "/install"), + "https://app.example.com/webapp/install", + ) + self.assertEqual( + subscription_mini_app_install_url(s), + "https://app.example.com/webapp/install", + ) + + def test_subscription_public_install_url_uses_origin(self): + s = Settings( + _env_file=None, + BOT_TOKEN="x", + POSTGRES_USER="u", + POSTGRES_PASSWORD="p", + SUBSCRIPTION_MINI_APP_URL="https://app.example.com/webapp", + ) + self.assertEqual( + subscription_public_install_url(s, "8f559061460e8fede78ef18dce887236"), + "https://app.example.com/s/8f559061460e8fede78ef18dce887236", + ) diff --git a/tests/test_settings.py b/tests/test_settings.py index aec7a25..95c1302 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -41,6 +41,7 @@ class SettingsTests(unittest.TestCase): ) self.assertTrue(settings.SUBSCRIPTION_GUIDES_ENABLED) + self.assertFalse(settings.SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED) self.assertTrue(settings.SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED) self.assertFalse(settings.SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED) self.assertEqual( diff --git a/tests/test_user_bot_menu.py b/tests/test_user_bot_menu.py index 32657e2..f5e6ebb 100644 --- a/tests/test_user_bot_menu.py +++ b/tests/test_user_bot_menu.py @@ -8,6 +8,7 @@ from bot.handlers.user import referral from bot.handlers.user.subscription.core import _with_subscription_purchase_description from bot.keyboards.inline.user_keyboards import ( get_bot_interface_inline_keyboard, + get_connect_and_main_keyboard, get_information_links_keyboard, get_language_selection_keyboard, get_main_menu_inline_keyboard, @@ -41,6 +42,13 @@ class UserBotMenuTests(unittest.TestCase): TERMS_OF_SERVICE_URL="", TRIAL_ENABLED=True, SERVER_STATUS_URL="", + SUBSCRIPTION_GUIDES_ENABLED=True, + SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED=False, + SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED=True, + SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED=False, + SUBSCRIPTION_PAGE_CONFIG_JSON="", + PANEL_API_URL="https://panel.example.com", + PANEL_API_KEY="token", ) def _callback_data(self, markup): @@ -70,6 +78,38 @@ class UserBotMenuTests(unittest.TestCase): self.assertIn("main_action:bot_info", callbacks) self.assertIn("main_action:back_to_main", callbacks) + def test_connect_keyboard_uses_subscription_url_when_bot_guides_disabled(self): + markup = get_connect_and_main_keyboard( + "en", + self.i18n, + self.settings, + "https://sb.example.com/user", + connect_button_url=None, + ) + + self.assertEqual(markup.inline_keyboard[0][0].url, "https://sb.example.com/user") + self.assertIsNone(markup.inline_keyboard[0][0].web_app) + + def test_connect_keyboard_opens_install_guide_when_bot_guides_enabled(self): + self.settings.SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED = True + markup = get_connect_and_main_keyboard( + "en", + self.i18n, + self.settings, + "https://sb.example.com/user", + install_share_url="https://app.example.com/s/8f559061460e8fede78ef18dce887236", + ) + + self.assertIsNone(markup.inline_keyboard[0][0].url) + self.assertEqual( + markup.inline_keyboard[0][0].web_app.url, + "https://app.example.com/install", + ) + self.assertEqual( + markup.inline_keyboard[1][0].url, + "https://app.example.com/s/8f559061460e8fede78ef18dce887236", + ) + def test_nested_bot_menu_keyboards_can_target_bot_interface_back(self): subscription_markup = get_subscription_options_keyboard( {1: 100},