feat: open bot install guides in mini app
This commit is contained in:
@@ -167,6 +167,16 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"Embedded install guides",
|
"Embedded install guides",
|
||||||
"Open install instructions inside the Web App instead of an external connect page.",
|
"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(
|
SettingField(
|
||||||
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
|
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
|
||||||
"bool",
|
"bool",
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ from bot.services.promo_code_service import PromoCodeService
|
|||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.states.user_states import UserPromoStates
|
from bot.states.user_states import UserPromoStates
|
||||||
from bot.utils.callback_answer import safe_answer_callback
|
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 config.settings import Settings
|
||||||
|
|
||||||
from .start import send_main_menu
|
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"),
|
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||||
config_link=config_link_text,
|
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(
|
reply_markup = get_connect_and_main_keyboard(
|
||||||
current_lang,
|
current_lang,
|
||||||
i18n,
|
i18n,
|
||||||
settings,
|
settings,
|
||||||
config_link_display,
|
config_link_display,
|
||||||
connect_button_url=connect_button_url,
|
connect_button_url=connect_button_url,
|
||||||
|
install_share_url=install_share_url,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ from bot.services.promo_code_service import PromoCodeService
|
|||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.utils.callback_answer import safe_answer_callback
|
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 bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import user_dal
|
from db.dal import user_dal
|
||||||
@@ -715,6 +719,23 @@ async def start_command_handler(
|
|||||||
),
|
),
|
||||||
config_link=config_link_text,
|
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
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
|
|
||||||
@@ -726,6 +747,7 @@ async def start_command_handler(
|
|||||||
settings,
|
settings,
|
||||||
config_link_display,
|
config_link_display,
|
||||||
connect_button_url=connect_button_url,
|
connect_button_url=connect_button_url,
|
||||||
|
install_share_url=install_share_url,
|
||||||
),
|
),
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ from bot.keyboards.inline.user_keyboards import (
|
|||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
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 config.settings import Settings
|
||||||
from db.dal import subscription_dal, user_billing_dal
|
from db.dal import subscription_dal, user_billing_dal
|
||||||
from db.models import Subscription
|
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(
|
local_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
session, event.from_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
|
# Build rows to prepend above the base "back" markup
|
||||||
prepend_rows = []
|
prepend_rows = []
|
||||||
|
|
||||||
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app
|
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app
|
||||||
cfg_link_val = connect_button_url or config_link_display
|
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(
|
prepend_rows.append(
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ from bot.services.notification_service import NotificationService
|
|||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.utils.config_link import prepare_config_links
|
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 config.settings import Settings
|
||||||
|
|
||||||
from .start import send_main_menu
|
from .start import send_main_menu
|
||||||
@@ -74,6 +78,7 @@ async def request_trial_confirmation_handler(
|
|||||||
config_link_display_for_trial = None
|
config_link_display_for_trial = None
|
||||||
config_link_for_trial = None
|
config_link_for_trial = None
|
||||||
connect_button_url_for_trial = None
|
connect_button_url_for_trial = None
|
||||||
|
install_share_url = None
|
||||||
|
|
||||||
if activation_result and activation_result.get("activated"):
|
if activation_result and activation_result.get("activated"):
|
||||||
try:
|
try:
|
||||||
@@ -104,6 +109,14 @@ async def request_trial_confirmation_handler(
|
|||||||
traffic_gb=traffic_display,
|
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
|
# Send notification to admin about new trial
|
||||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||||
@@ -139,6 +152,7 @@ async def request_trial_confirmation_handler(
|
|||||||
settings,
|
settings,
|
||||||
config_link_display_for_trial,
|
config_link_display_for_trial,
|
||||||
connect_button_url=connect_button_url_for_trial,
|
connect_button_url=connect_button_url_for_trial,
|
||||||
|
install_share_url=install_share_url,
|
||||||
)
|
)
|
||||||
if activation_result and activation_result.get("activated")
|
if activation_result and activation_result.get("activated")
|
||||||
else get_main_menu_inline_keyboard(
|
else get_main_menu_inline_keyboard(
|
||||||
@@ -214,6 +228,7 @@ async def confirm_activate_trial_handler(
|
|||||||
config_link_display_for_trial = None
|
config_link_display_for_trial = None
|
||||||
config_link_for_trial = None
|
config_link_for_trial = None
|
||||||
connect_button_url_for_trial = None
|
connect_button_url_for_trial = None
|
||||||
|
install_share_url = None
|
||||||
|
|
||||||
if activation_result and activation_result.get("activated"):
|
if activation_result and activation_result.get("activated"):
|
||||||
try:
|
try:
|
||||||
@@ -243,6 +258,13 @@ async def confirm_activate_trial_handler(
|
|||||||
config_link=config_link_for_trial,
|
config_link=config_link_for_trial,
|
||||||
traffic_gb=traffic_display,
|
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:
|
else:
|
||||||
message_key_from_service = (
|
message_key_from_service = (
|
||||||
activation_result.get("message_key", "trial_activation_failed")
|
activation_result.get("message_key", "trial_activation_failed")
|
||||||
@@ -266,6 +288,7 @@ async def confirm_activate_trial_handler(
|
|||||||
settings,
|
settings,
|
||||||
config_link_display_for_trial,
|
config_link_display_for_trial,
|
||||||
connect_button_url=connect_button_url_for_trial,
|
connect_button_url=connect_button_url_for_trial,
|
||||||
|
install_share_url=install_share_url,
|
||||||
)
|
)
|
||||||
if activation_result and activation_result.get("activated")
|
if activation_result and activation_result.get("activated")
|
||||||
else get_main_menu_inline_keyboard(
|
else get_main_menu_inline_keyboard(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||||||
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||||
|
|
||||||
|
from bot.utils.install_links import bot_install_guide_url
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
BOT_MENU_CONTEXT = "bot"
|
BOT_MENU_CONTEXT = "bot"
|
||||||
@@ -689,13 +690,29 @@ def get_connect_and_main_keyboard(
|
|||||||
config_link: Optional[str],
|
config_link: Optional[str],
|
||||||
connect_button_url: Optional[str] = None,
|
connect_button_url: Optional[str] = None,
|
||||||
preserve_message: bool = False,
|
preserve_message: bool = False,
|
||||||
|
install_share_url: Optional[str] = None,
|
||||||
) -> InlineKeyboardMarkup:
|
) -> InlineKeyboardMarkup:
|
||||||
"""Keyboard with a connect button and a back to main menu button."""
|
"""Keyboard with a connect button and a back to main menu button."""
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
install_url = bot_install_guide_url(settings)
|
||||||
button_target = connect_button_url or config_link
|
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))
|
builder.row(InlineKeyboardButton(text=_("connect_button"), url=button_target))
|
||||||
elif settings.SUBSCRIPTION_MINI_APP_URL:
|
elif settings.SUBSCRIPTION_MINI_APP_URL:
|
||||||
builder.row(
|
builder.row(
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
from bot.utils.config_link import prepare_config_links
|
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 bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
from db.dal import payment_dal, user_dal
|
from db.dal import payment_dal, user_dal
|
||||||
from db.models import Payment, User
|
from db.models import Payment, User
|
||||||
@@ -136,6 +140,7 @@ async def send_success_message_to_user(
|
|||||||
settings: Any,
|
settings: Any,
|
||||||
config_link_display: Optional[str],
|
config_link_display: Optional[str],
|
||||||
connect_button_url: Optional[str],
|
connect_button_url: Optional[str],
|
||||||
|
install_share_url: Optional[str] = None,
|
||||||
include_keyboard: bool = True,
|
include_keyboard: bool = True,
|
||||||
log_prefix: str = "payment_providers",
|
log_prefix: str = "payment_providers",
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -148,6 +153,7 @@ async def send_success_message_to_user(
|
|||||||
settings,
|
settings,
|
||||||
config_link_display,
|
config_link_display,
|
||||||
connect_button_url=connect_button_url,
|
connect_button_url=connect_button_url,
|
||||||
|
install_share_url=install_share_url,
|
||||||
preserve_message=True,
|
preserve_message=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -332,6 +338,31 @@ async def finalize_successful_payment(
|
|||||||
if req.text_prefix:
|
if req.text_prefix:
|
||||||
success_text = f"{req.text_prefix}\n{success_text}"
|
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(
|
await send_success_message_to_user(
|
||||||
bot=req.bot,
|
bot=req.bot,
|
||||||
user_id=req.user_id,
|
user_id=req.user_id,
|
||||||
@@ -341,6 +372,7 @@ async def finalize_successful_payment(
|
|||||||
settings=req.settings,
|
settings=req.settings,
|
||||||
config_link_display=config_link_display,
|
config_link_display=config_link_display,
|
||||||
connect_button_url=connect_button_url,
|
connect_button_url=connect_button_url,
|
||||||
|
install_share_url=install_share_url,
|
||||||
include_keyboard=not req.skip_keyboard,
|
include_keyboard=not req.skip_keyboard,
|
||||||
log_prefix=req.log_prefix,
|
log_prefix=req.log_prefix,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ from bot.services.panel_api_service import PanelApiService
|
|||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.utils.config_link import prepare_config_links
|
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 bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import payment_dal, user_billing_dal, user_dal
|
from db.dal import payment_dal, user_billing_dal, user_dal
|
||||||
@@ -741,6 +745,16 @@ async def process_successful_payment(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
include_keyboard = True
|
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(
|
await send_success_message_to_user(
|
||||||
bot=bot,
|
bot=bot,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -750,6 +764,7 @@ async def process_successful_payment(
|
|||||||
settings=settings,
|
settings=settings,
|
||||||
config_link_display=config_link_display,
|
config_link_display=config_link_display,
|
||||||
connect_button_url=connect_button_url,
|
connect_button_url=connect_button_url,
|
||||||
|
install_share_url=install_share_url,
|
||||||
include_keyboard=include_keyboard,
|
include_keyboard=include_keyboard,
|
||||||
log_prefix="YooKassa webhook",
|
log_prefix="YooKassa webhook",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<code>{public_share_url}</code>"
|
||||||
|
return f"{text}{share_line}"
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
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 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:
|
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
|
return None
|
||||||
normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular"
|
normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular"
|
||||||
return append_query_params(base, {"topup": normalized})
|
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)}"
|
||||||
|
|||||||
@@ -327,6 +327,13 @@ class Settings(BaseSettings):
|
|||||||
default=True,
|
default=True,
|
||||||
description="Show embedded install instructions inside the subscription Mini App.",
|
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(
|
SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="Use Remnawave Panel Subscription Page config for embedded guides when available.",
|
description="Use Remnawave Panel Subscription Page config for embedded guides when available.",
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
|
"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",
|
"back_to_payment_methods_button": "⬅️ Back",
|
||||||
"connect_button": "🔗 Connect",
|
"connect_button": "🔗 Connect",
|
||||||
|
"install_guide_share_button": "🔗 Share install guide",
|
||||||
|
"install_guide_share_link_line": "\n\nInstall guide for sharing:\n<code>{install_share_link}</code>",
|
||||||
"cancel_button": "❌ Cancel",
|
"cancel_button": "❌ Cancel",
|
||||||
"devices_button": "📱 My Devices ({current_devices}/{max_devices})",
|
"devices_button": "📱 My Devices ({current_devices}/{max_devices})",
|
||||||
"my_devices_details": "📱 <b>My Devices ({current_devices}/{max_devices})</b>\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n<blockquote><i>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.</i></blockquote>",
|
"my_devices_details": "📱 <b>My Devices ({current_devices}/{max_devices})</b>\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n<blockquote><i>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.</i></blockquote>",
|
||||||
@@ -1618,6 +1620,8 @@
|
|||||||
"admin_settings_section_subscription_guides": "Install guides",
|
"admin_settings_section_subscription_guides": "Install guides",
|
||||||
"admin_settings_field_subscription_guides_enabled_label": "Embedded 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_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_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_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",
|
"admin_settings_field_subscription_page_config_json_override_enabled_label": "Enable admin JSON override",
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
|
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
|
||||||
"back_to_payment_methods_button": "⬅️ Назад",
|
"back_to_payment_methods_button": "⬅️ Назад",
|
||||||
"connect_button": "🔗 Подключиться",
|
"connect_button": "🔗 Подключиться",
|
||||||
|
"install_guide_share_button": "🔗 Поделиться инструкцией",
|
||||||
|
"install_guide_share_link_line": "\n\nИнструкция для передачи:\n<code>{install_share_link}</code>",
|
||||||
"devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
|
"devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
|
||||||
"my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>",
|
"my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>",
|
||||||
"no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
|
"no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
|
||||||
@@ -1618,6 +1620,8 @@
|
|||||||
"admin_settings_section_subscription_guides": "Инструкции подключения",
|
"admin_settings_section_subscription_guides": "Инструкции подключения",
|
||||||
"admin_settings_field_subscription_guides_enabled_label": "Встроенные инструкции подключения",
|
"admin_settings_field_subscription_guides_enabled_label": "Встроенные инструкции подключения",
|
||||||
"admin_settings_field_subscription_guides_enabled_description": "Открывать инструкции прямо внутри Web App вместо внешней страницы подключения.",
|
"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_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_panel_enabled_description": "Брать Subscription Page config из Remnawave Panel по short UUID подписки пользователя.",
|
||||||
"admin_settings_field_subscription_page_config_json_override_enabled_label": "Включить JSON-override из админки",
|
"admin_settings_field_subscription_page_config_json_override_enabled_label": "Включить JSON-override из админки",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS = (
|
|||||||
|
|
||||||
SUBSCRIPTION_GUIDE_SETTINGS = (
|
SUBSCRIPTION_GUIDE_SETTINGS = (
|
||||||
"SUBSCRIPTION_GUIDES_ENABLED",
|
"SUBSCRIPTION_GUIDES_ENABLED",
|
||||||
|
"SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED",
|
||||||
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
|
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
|
||||||
"SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED",
|
"SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED",
|
||||||
"SUBSCRIPTION_PAGE_CONFIG_PATH",
|
"SUBSCRIPTION_PAGE_CONFIG_PATH",
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import unittest
|
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
|
from config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
@@ -39,3 +45,33 @@ class MiniAppUrlTests(unittest.TestCase):
|
|||||||
subscription_mini_app_topup_url(s, "regular"),
|
subscription_mini_app_topup_url(s, "regular"),
|
||||||
"https://app.example.com/webapp?topup=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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class SettingsTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(settings.SUBSCRIPTION_GUIDES_ENABLED)
|
self.assertTrue(settings.SUBSCRIPTION_GUIDES_ENABLED)
|
||||||
|
self.assertFalse(settings.SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED)
|
||||||
self.assertTrue(settings.SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED)
|
self.assertTrue(settings.SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED)
|
||||||
self.assertFalse(settings.SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED)
|
self.assertFalse(settings.SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from bot.handlers.user import referral
|
|||||||
from bot.handlers.user.subscription.core import _with_subscription_purchase_description
|
from bot.handlers.user.subscription.core import _with_subscription_purchase_description
|
||||||
from bot.keyboards.inline.user_keyboards import (
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
get_bot_interface_inline_keyboard,
|
get_bot_interface_inline_keyboard,
|
||||||
|
get_connect_and_main_keyboard,
|
||||||
get_information_links_keyboard,
|
get_information_links_keyboard,
|
||||||
get_language_selection_keyboard,
|
get_language_selection_keyboard,
|
||||||
get_main_menu_inline_keyboard,
|
get_main_menu_inline_keyboard,
|
||||||
@@ -41,6 +42,13 @@ class UserBotMenuTests(unittest.TestCase):
|
|||||||
TERMS_OF_SERVICE_URL="",
|
TERMS_OF_SERVICE_URL="",
|
||||||
TRIAL_ENABLED=True,
|
TRIAL_ENABLED=True,
|
||||||
SERVER_STATUS_URL="",
|
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):
|
def _callback_data(self, markup):
|
||||||
@@ -70,6 +78,38 @@ class UserBotMenuTests(unittest.TestCase):
|
|||||||
self.assertIn("main_action:bot_info", callbacks)
|
self.assertIn("main_action:bot_info", callbacks)
|
||||||
self.assertIn("main_action:back_to_main", 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):
|
def test_nested_bot_menu_keyboards_can_target_bot_interface_back(self):
|
||||||
subscription_markup = get_subscription_options_keyboard(
|
subscription_markup = get_subscription_options_keyboard(
|
||||||
{1: 100},
|
{1: 100},
|
||||||
|
|||||||
Reference in New Issue
Block a user