diff --git a/README.md b/README.md index c50f36b..621cbcd 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,9 @@ Web App запускается в том же контейнере, что и б docker compose up -d --build ``` -После этого кнопка «Моя подписка» в меню бота откроет Web App. Web App показывает текущую ссылку подключения, остаток времени, трафик, оплату и блок аккаунта. Пользователь может привязать email к Telegram-аккаунту через код из письма или привязать Telegram к email-аккаунту через Telegram OAuth Login. После привязки вход работает обоими способами. +После этого кнопка «Личный кабинет» в меню бота откроет Web App. Рядом доступна кнопка «Бот-меню» для открытия расширенного интерфейса в чате без команды `/tg`, но основной сценарий управления подпиской удобнее проходить в личном кабинете. Web App показывает текущую ссылку подключения, остаток времени, трафик, оплату и блок аккаунта. Пользователь может привязать email к Telegram-аккаунту через код из письма или привязать Telegram к email-аккаунту через Telegram OAuth Login. После привязки вход работает обоими способами. + +Реферальные ссылки доступны в двух форматах: Telegram deep-link `https://t.me/?start=ref_u` и Web App ссылка с query-параметром `ref=u`. Web App учитывает `ref`, `start`, `start_param` и Telegram Mini Apps `start_param`, сохраняет найденный параметр до авторизации и передаёт его в Telegram OAuth и email-вход, чтобы регистрация корректно привязалась к пригласившему. Для email-регистраций пользователь в панели Remnawave создается с анонимным username вида `em_`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_`. diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py index 62e9045..044463d 100644 --- a/bot/handlers/user/promo_user.py +++ b/bot/handlers/user/promo_user.py @@ -31,7 +31,8 @@ MAX_PROMO_CODE_INPUT_LENGTH = 100 async def prompt_promo_code_input(callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, - settings: Settings, session: AsyncSession): + settings: Settings, session: AsyncSession, + back_callback: str = "main_action:back_to_main"): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: @@ -52,14 +53,22 @@ async def prompt_promo_code_input(callback: types.CallbackQuery, try: await callback.message.edit_text( text=_(key="promo_code_prompt"), - reply_markup=get_back_to_main_menu_markup(current_lang, i18n)) + reply_markup=get_back_to_main_menu_markup( + current_lang, + i18n, + callback_data=back_callback, + )) except Exception as e_edit: logging.warning( f"Failed to edit message for promo prompt: {e_edit}. Sending new one." ) await callback.message.answer( text=_(key="promo_code_prompt"), - reply_markup=get_back_to_main_menu_markup(current_lang, i18n)) + reply_markup=get_back_to_main_menu_markup( + current_lang, + i18n, + callback_data=back_callback, + )) await safe_answer_callback(callback) await state.set_state(UserPromoStates.waiting_for_promo_code) diff --git a/bot/handlers/user/referral.py b/bot/handlers/user/referral.py index f37d386..0122096 100644 --- a/bot/handlers/user/referral.py +++ b/bot/handlers/user/referral.py @@ -9,17 +9,16 @@ from config.settings import Settings from db.dal import user_dal from bot.services.referral_service import ReferralService -from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup from bot.middlewares.i18n import JsonI18n router = Router(name="user_referral_router") -async def referral_command_handler(event: Union[types.Message, - types.CallbackQuery], +async def referral_command_handler(event: Union[types.Message, types.CallbackQuery], settings: Settings, i18n_data: dict, referral_service: ReferralService, bot: Bot, - session: AsyncSession): + session: AsyncSession, + back_callback: str = "main_action:back_to_main"): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") @@ -97,27 +96,35 @@ async def referral_command_handler(event: Union[types.Message, bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _( "referral_no_bonuses_configured") - # Get referral statistics referral_stats = await referral_service.get_referral_stats(session, inviter_user_id) + webapp_referral_link = await _generate_webapp_referral_link( + session, + settings, + inviter_user_id, + ) + webapp_link_section = ( + _( + "referral_webapp_link_line", + webapp_referral_link=webapp_referral_link, + ) + if webapp_referral_link + else "" + ) + text = _("referral_program_info_new", referral_link=referral_link, + webapp_link_section=webapp_link_section, bonus_details=bonus_details_str, invited_count=referral_stats["invited_count"], purchased_count=referral_stats["purchased_count"]) - if settings.SUBSCRIPTION_MINI_APP_URL: - db_user = await user_dal.get_user_by_id(session, inviter_user_id) - referral_code = await user_dal.ensure_referral_code(session, db_user) if db_user else None - webapp_referral_link = _build_webapp_referral_link( - settings.SUBSCRIPTION_MINI_APP_URL, - referral_code, - ) - if webapp_referral_link: - webapp_label = "Web App ссылка" if current_lang == "ru" else "Web App link" - text += f"\n\n🔗 {webapp_label}:\n{webapp_referral_link}" from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard - reply_markup_val = get_referral_link_keyboard(current_lang, i18n) + reply_markup_val = get_referral_link_keyboard( + current_lang, + i18n, + back_callback=back_callback, + ) if isinstance(event, types.Message): await event.answer(text, @@ -152,7 +159,7 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti bot_info = await bot.get_me() bot_username = bot_info.username if not bot_username: - await callback.answer("Ошибка получения имени бота", show_alert=True) + await callback.answer(_("error_generating_referral_link"), show_alert=True) return inviter_user_id = callback.from_user.id @@ -166,18 +173,30 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti ) await callback.answer(_("error_generating_referral_link"), show_alert=True) return - - friend_message = _("referral_friend_message", referral_link=referral_link) - + + webapp_referral_link = await _generate_webapp_referral_link( + session, + settings, + inviter_user_id, + ) + if webapp_referral_link: + friend_message = _( + "referral_friend_message_with_webapp", + referral_link=referral_link, + webapp_referral_link=webapp_referral_link, + ) + else: + friend_message = _("referral_friend_message", referral_link=referral_link) + await callback.message.answer( friend_message, disable_web_page_preview=True ) - + except Exception as e: logging.error(f"Error in referral share message: {e}") - await callback.answer("Произошла ошибка", show_alert=True) - + await callback.answer(_("error_occurred_try_again"), show_alert=True) + await callback.answer() @@ -196,3 +215,25 @@ def _build_webapp_referral_link(base_url: Optional[str], referral_code: Optional parts.fragment, ) ) + + +async def _generate_webapp_referral_link( + session: AsyncSession, + settings: Settings, + inviter_user_id: int, +) -> Optional[str]: + if not settings.SUBSCRIPTION_MINI_APP_URL: + return None + db_user = await user_dal.get_user_by_id(session, inviter_user_id) + referral_code = await user_dal.ensure_referral_code(session, db_user) if db_user else None + return _build_webapp_referral_link( + settings.SUBSCRIPTION_MINI_APP_URL, + referral_code, + ) + + +@router.message(Command("referral")) +async def referral_command_message_handler(message: types.Message, settings: Settings, + i18n_data: dict, referral_service: ReferralService, + bot: Bot, session: AsyncSession): + await referral_command_handler(message, settings, i18n_data, referral_service, bot, session) diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index 0a72ca5..4fd24c4 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -152,6 +152,11 @@ async def send_bot_interface_menu( settings, subscription_service, session, user_id) text = i18n.gettext(current_lang, "bot_interface_menu_title") + if settings.SUBSCRIPTION_MINI_APP_URL: + text = ( + f"{text}\n\n" + f"{i18n.gettext(current_lang, 'bot_interface_menu_webapp_hint')}" + ) reply_markup = get_bot_interface_inline_keyboard( current_lang, i18n, settings, show_trial_button_in_menu) @@ -797,6 +802,7 @@ async def language_command_handler( event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, + back_callback: str = "main_action:back_to_main", ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") @@ -804,7 +810,11 @@ async def language_command_handler( ) if i18n else key text_to_send = _(key="choose_language") - reply_markup = get_language_selection_keyboard(i18n, current_lang) + reply_markup = get_language_selection_keyboard( + i18n, + current_lang, + back_callback=back_callback, + ) target_message_obj = event.message if isinstance( event, types.CallbackQuery) else event @@ -845,7 +855,8 @@ async def select_language_callback_handler( return try: - lang_code = callback.data.split("_")[2] + lang_payload = callback.data.split("_", 2)[2] + lang_code, _, return_target = lang_payload.partition(":") except IndexError: await safe_answer_callback( callback, @@ -879,12 +890,20 @@ async def select_language_callback_handler( exc_info=True) await safe_answer_callback(callback, "Error setting language.", show_alert=True) return - await send_main_menu(callback, - settings, - i18n_data, - subscription_service, - session, - is_edit=True) + if return_target == "bot": + await send_bot_interface_menu(callback, + settings, + i18n_data, + subscription_service, + session, + is_edit=True) + else: + await send_main_menu(callback, + settings, + i18n_data, + subscription_service, + session, + is_edit=True) @router.callback_query(F.data.startswith("main_action:")) @@ -896,6 +915,9 @@ async def main_action_callback_handler( action = callback.data.split(":")[1] user_id = callback.from_user.id + if action in {"back_to_main", "back_to_main_keep", "bot_interface"}: + await state.clear() + from . import subscription as user_subscription_handlers from . import referral as user_referral_handlers from . import promo_user as user_promo_handlers @@ -912,10 +934,29 @@ async def main_action_callback_handler( if action == "subscribe": await user_subscription_handlers.display_subscription_options( callback, i18n_data, settings, session) + elif action == "bot_subscribe": + await user_subscription_handlers.display_subscription_options( + callback, + i18n_data, + settings, + session, + back_callback="main_action:bot_interface", + ) elif action == "my_subscription": await user_subscription_handlers.my_subscription_command_handler( callback, i18n_data, settings, panel_service, subscription_service, session, bot) + elif action == "bot_my_subscription": + await user_subscription_handlers.my_subscription_command_handler( + callback, + i18n_data, + settings, + panel_service, + subscription_service, + session, + bot, + back_callback="main_action:bot_interface", + ) elif action == "my_devices": await user_subscription_handlers.my_devices_command_handler( callback, i18n_data, settings, panel_service, subscription_service, @@ -923,16 +964,49 @@ async def main_action_callback_handler( elif action == "referral": await user_referral_handlers.referral_command_handler( callback, settings, i18n_data, referral_service, bot, session) + elif action == "bot_referral": + await user_referral_handlers.referral_command_handler( + callback, + settings, + i18n_data, + referral_service, + bot, + session, + back_callback="main_action:bot_interface", + ) elif action == "apply_promo": await user_promo_handlers.prompt_promo_code_input( callback, state, i18n_data, settings, session) + elif action == "bot_apply_promo": + await user_promo_handlers.prompt_promo_code_input( + callback, + state, + i18n_data, + settings, + session, + back_callback="main_action:bot_interface", + ) elif action == "request_trial": await user_trial_handlers.request_trial_confirmation_handler( callback, settings, i18n_data, subscription_service, session) elif action == "language": await language_command_handler(callback, i18n_data, settings) - elif action == "info": + elif action == "bot_language": + await language_command_handler( + callback, + i18n_data, + settings, + back_callback="main_action:bot_interface", + ) + elif action == "bot_interface": + await send_bot_interface_menu(callback, + settings, + i18n_data, + subscription_service, + session, + is_edit=True) + elif action in {"info", "bot_info"}: i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) @@ -962,6 +1036,11 @@ async def main_action_callback_handler( i18n, privacy_url, user_agreement_url, + back_callback=( + "main_action:bot_interface" + if callback.data == "main_action:bot_info" + else "main_action:back_to_main" + ), ) try: await callback.message.edit_text(_(key="info_links_message"), diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 9f86679..bd0e550 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -39,7 +39,13 @@ def _hwid_callback_token(hwid: Optional[str]) -> str: return hashlib.sha256(hwid_str.encode()).hexdigest()[:32] -async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession): +async def display_subscription_options( + event: Union[types.Message, types.CallbackQuery], + i18n_data: dict, + settings: Settings, + session: AsyncSession, + back_callback: str = "main_action:back_to_main", +): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") @@ -75,11 +81,20 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac if options: text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period") reply_markup = get_subscription_options_keyboard( - options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode + options, + currency_symbol_val, + current_lang, + i18n, + traffic_mode=traffic_mode, + back_callback=back_callback, ) else: text_content = get_text("no_subscription_options_available") - reply_markup = get_back_to_main_menu_markup(current_lang, i18n) + reply_markup = get_back_to_main_menu_markup( + current_lang, + i18n, + callback_data=back_callback, + ) target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event if not target_message_obj: @@ -116,6 +131,7 @@ async def my_subscription_command_handler( subscription_service: SubscriptionService, session: AsyncSession, bot: Bot, + back_callback: str = "main_action:back_to_main", ): target = event.message if isinstance(event, types.CallbackQuery) else event current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) @@ -139,7 +155,11 @@ async def my_subscription_command_handler( buy_button = InlineKeyboardButton( text=get_text("menu_subscribe_inline"), callback_data="main_action:subscribe" ) - back_markup = get_back_to_main_menu_markup(current_lang, i18n) + back_markup = get_back_to_main_menu_markup( + current_lang, + i18n, + callback_data=back_callback, + ) kb = InlineKeyboardMarkup(inline_keyboard=[[buy_button], *back_markup.inline_keyboard]) @@ -231,7 +251,11 @@ async def my_subscription_command_handler( traffic_period=period_label, ) - base_markup = get_back_to_main_menu_markup(current_lang, i18n) + base_markup = get_back_to_main_menu_markup( + current_lang, + i18n, + callback_data=back_callback, + ) kb = base_markup.inline_keyboard try: local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id) diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 36f7427..996ef08 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -28,11 +28,21 @@ def get_main_menu_inline_keyboard( ) ) + builder.row( + InlineKeyboardButton(text=_(key="menu_bot_interface_button"), + callback_data="main_action:bot_interface")) + if settings.SUPPORT_LINK: builder.row( InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)) + user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL + if settings.PRIVACY_POLICY_URL or user_agreement_url: + builder.row( + InlineKeyboardButton(text=_(key="menu_info_button"), + callback_data="main_action:info")) + return builder.as_markup() @@ -59,26 +69,26 @@ def get_bot_interface_inline_keyboard( builder.row( InlineKeyboardButton(text=_(key="menu_subscribe_inline"), - callback_data="main_action:subscribe")) + callback_data="main_action:bot_subscribe")) builder.row( InlineKeyboardButton( text=_(key="menu_my_subscription_inline"), - callback_data="main_action:my_subscription", + callback_data="main_action:bot_my_subscription", ) ) referral_button = InlineKeyboardButton( text=_(key="menu_referral_inline"), - callback_data="main_action:referral") + callback_data="main_action:bot_referral") promo_button = InlineKeyboardButton( text=_(key="menu_apply_promo_button"), - callback_data="main_action:apply_promo") + callback_data="main_action:bot_apply_promo") builder.row(referral_button) builder.row(promo_button) language_button = InlineKeyboardButton( text=_(key="menu_language_settings_inline"), - callback_data="main_action:language") + callback_data="main_action:bot_language") status_button_list = [] if settings.SERVER_STATUS_URL: status_button_list.append( @@ -99,7 +109,11 @@ def get_bot_interface_inline_keyboard( if settings.PRIVACY_POLICY_URL or user_agreement_url: builder.row( InlineKeyboardButton(text=_(key="menu_info_button"), - callback_data="main_action:info")) + callback_data="main_action:bot_info")) + + builder.row( + InlineKeyboardButton(text=_(key="back_to_main_menu_button"), + callback_data="main_action:back_to_main")) return builder.as_markup() @@ -108,7 +122,8 @@ def get_information_links_keyboard( lang: str, i18n_instance, privacy_policy_url: Optional[str], - user_agreement_url: Optional[str]) -> InlineKeyboardMarkup: + user_agreement_url: Optional[str], + back_callback: str = "main_action:back_to_main") -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() if privacy_policy_url: @@ -121,21 +136,23 @@ def get_information_links_keyboard( url=user_agreement_url)) builder.row( InlineKeyboardButton(text=_(key="back_to_main_menu_button"), - callback_data="main_action:back_to_main")) + callback_data=back_callback)) return builder.as_markup() def get_language_selection_keyboard(i18n_instance, - current_lang: str) -> InlineKeyboardMarkup: + current_lang: str, + back_callback: str = "main_action:back_to_main") -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs ) + callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else "" builder = InlineKeyboardBuilder() builder.button(text=f"🇬🇧 English {'✅' if current_lang == 'en' else ''}", - callback_data="set_lang_en") + callback_data=f"set_lang_en{callback_suffix}") builder.button(text=f"🇷🇺 Русский {'✅' if current_lang == 'ru' else ''}", - callback_data="set_lang_ru") + callback_data=f"set_lang_ru{callback_suffix}") builder.button(text=_(key="back_to_main_menu_button"), - callback_data="main_action:back_to_main") + callback_data=back_callback) builder.adjust(1) return builder.as_markup() @@ -154,7 +171,8 @@ def get_trial_confirmation_keyboard(lang: str, def get_subscription_options_keyboard(subscription_options: Dict[ float, Optional[float]], currency_symbol_val: str, lang: str, - i18n_instance, traffic_mode: bool = False) -> InlineKeyboardMarkup: + i18n_instance, traffic_mode: bool = False, + back_callback: str = "main_action:back_to_main") -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() def _format_gb(val: float) -> str: @@ -181,7 +199,7 @@ def get_subscription_options_keyboard(subscription_options: Dict[ builder.adjust(1) builder.row( InlineKeyboardButton(text=_(key="back_to_main_menu_button"), - callback_data="main_action:back_to_main")) + callback_data=back_callback)) return builder.as_markup() @@ -365,13 +383,14 @@ def get_yk_saved_cards_keyboard( def get_referral_link_keyboard(lang: str, - i18n_instance) -> InlineKeyboardMarkup: + i18n_instance, + back_callback: str = "main_action:back_to_main") -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() builder.button(text=_(key="referral_share_message_button"), callback_data="referral_action:share_message") builder.button(text=_(key="back_to_main_menu_button"), - callback_data="main_action:back_to_main") + callback_data=back_callback) builder.adjust(1) return builder.as_markup() diff --git a/locales/en.json b/locales/en.json index b4280ad..7cb48f2 100644 --- a/locales/en.json +++ b/locales/en.json @@ -11,6 +11,7 @@ "menu_activate_trial_button": "🆓 Free Trial", "menu_subscribe_inline": "🚀 Purchase", "menu_personal_account_button": "🔑 Personal Account", + "menu_bot_interface_button": "🤖 Bot menu", "menu_my_subscription_inline": "🔐 My Subscription", "no_subscription_options_available": "Subscription issuance is not configured by the bot administrator", "menu_referral_inline": "🎁 Invite friend", @@ -22,6 +23,7 @@ "menu_terms_button": "📄 Terms of Service", "menu_info_button": "ℹ️ Information", "bot_interface_menu_title": "Bot interface", + "bot_interface_menu_webapp_hint": "The Personal Account is the easier place to manage the service.", "info_links_message": "Choose a document:", "privacy_policy_button": "🔒 Privacy Policy", "user_agreement_button": "📄 User Agreement", @@ -100,11 +102,13 @@ "referral_welcome_bonus_applied": "🎁 You have received {days} bonus day(s) for registering via a referral link!\nYour subscription is active until {end_date}.", "yes_button": "Yes", "no_button": "No", - "referral_program_info_new": "🎁 Referral Program\n\n📊 Your stats:\n👥 Friends invited: {invited_count}\n💳 Purchased subscription: {purchased_count}\n\n🔗 Your link:\n{referral_link}\n\n💰 Invitation bonuses:\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!", + "referral_program_info_new": "🎁 Referral Program\n\n📊 Your stats:\n👥 Friends invited: {invited_count}\n💳 Purchased subscription: {purchased_count}\n\n🔗 Telegram link:\n{referral_link}{webapp_link_section}\n\n💰 Invitation bonuses:\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!", "referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: {inviter_bonus_days} days\n ➢ Friend: {referee_bonus_days} days", "referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.", "referral_share_message_button": "📩 Message for friend", "referral_friend_message": "🚀 Hey! Try this service - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}", + "referral_friend_message_with_webapp": "🚀 Hey! Try this service - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\nTelegram: {referral_link}\nWeb: {webapp_referral_link}", + "referral_webapp_link_line": "\n🌐 Web link:\n{webapp_referral_link}", "friend_placeholder": "friend", "referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.", "referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.", diff --git a/locales/ru.json b/locales/ru.json index e1ed54b..1cc542b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -11,6 +11,7 @@ "menu_activate_trial_button": "🆓 Пробный период", "menu_subscribe_inline": "🚀 Купить", "menu_personal_account_button": "🔑 Личный кабинет", + "menu_bot_interface_button": "🤖 Бот-меню", "menu_my_subscription_inline": "🔐 Моя подписка", "no_subscription_options_available": "Выдача подписки не настроена администратором бота", "menu_referral_inline": "🎁 Пригласить друга", @@ -22,6 +23,7 @@ "menu_terms_button": "📄 Условия сервиса", "menu_info_button": "ℹ️ Информация", "bot_interface_menu_title": "Интерфейс в боте", + "bot_interface_menu_webapp_hint": "В личном кабинете взаимодействовать с сервисом будет удобнее.", "info_links_message": "Выберите документ:", "privacy_policy_button": "🔒 Политика конфиденциальности", "user_agreement_button": "📄 Пользовательское соглашение", @@ -100,11 +102,13 @@ "referral_welcome_bonus_applied": "🎁 Вам начислено {days} бонусных дн. за регистрацию по реферальной ссылке!\nПодписка активна до {end_date}.", "yes_button": "Да", "no_button": "Нет", - "referral_program_info_new": "🎁 Реферальная программа\n\n📊 Твоя статистика:\n👥 Приглашено друзей: {invited_count}\n💳 Купили подписку: {purchased_count}\n\n🔗 Твоя ссылка:\n{referral_link}\n\n💰 Бонусы за приглашения:\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!", + "referral_program_info_new": "🎁 Реферальная программа\n\n📊 Твоя статистика:\n👥 Приглашено друзей: {invited_count}\n💳 Купили подписку: {purchased_count}\n\n🔗 Telegram ссылка:\n{referral_link}{webapp_link_section}\n\n💰 Бонусы за приглашения:\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!", "referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: {inviter_bonus_days} дн.\n ➢ Друг: {referee_bonus_days} дн.", "referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.", "referral_share_message_button": "📩 Сообщение для друга", "referral_friend_message": "🚀 Привет! Попробуй этот сервис - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", + "referral_friend_message_with_webapp": "🚀 Привет! Попробуй этот сервис - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\nTelegram: {referral_link}\nWeb: {webapp_referral_link}", + "referral_webapp_link_line": "\n🌐 Web ссылка:\n{webapp_referral_link}", "friend_placeholder": "друг", "referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.", "referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.", diff --git a/tests/test_user_bot_menu.py b/tests/test_user_bot_menu.py new file mode 100644 index 0000000..d3d91b1 --- /dev/null +++ b/tests/test_user_bot_menu.py @@ -0,0 +1,129 @@ +import json +import unittest +from pathlib import Path +from types import SimpleNamespace + +from bot.app.web import subscription_webapp +from bot.handlers.user import referral +from bot.keyboards.inline.user_keyboards import ( + get_bot_interface_inline_keyboard, + get_information_links_keyboard, + get_language_selection_keyboard, + get_main_menu_inline_keyboard, + get_referral_link_keyboard, + get_subscription_options_keyboard, +) + + +class JsonI18nStub: + def __init__(self): + self.translations = json.loads( + Path("locales/en.json").read_text(encoding="utf-8") + ) + + def gettext(self, lang, key, **kwargs): + text = self.translations[key] + return text.format(**kwargs) if kwargs else text + + +class UserBotMenuTests(unittest.TestCase): + def setUp(self): + self.i18n = JsonI18nStub() + self.settings = SimpleNamespace( + SUBSCRIPTION_MINI_APP_URL="https://app.example.com/", + SUPPORT_LINK="https://t.me/support", + PRIVACY_POLICY_URL="https://example.com/privacy", + USER_AGREEMENT_URL="https://example.com/agreement", + TERMS_OF_SERVICE_URL="", + TRIAL_ENABLED=True, + SERVER_STATUS_URL="", + ) + + def _callback_data(self, markup): + return [ + button.callback_data + for row in markup.inline_keyboard + for button in row + if button.callback_data + ] + + def test_main_menu_exposes_bot_menu_and_information(self): + markup = get_main_menu_inline_keyboard("en", self.i18n, self.settings) + + callbacks = self._callback_data(markup) + + self.assertIn("main_action:bot_interface", callbacks) + self.assertIn("main_action:info", callbacks) + + def test_bot_interface_buttons_return_to_bot_interface(self): + markup = get_bot_interface_inline_keyboard("en", self.i18n, self.settings) + + callbacks = self._callback_data(markup) + + self.assertIn("main_action:bot_subscribe", callbacks) + self.assertIn("main_action:bot_my_subscription", callbacks) + self.assertIn("main_action:bot_referral", callbacks) + self.assertIn("main_action:bot_info", callbacks) + self.assertIn("main_action:back_to_main", callbacks) + + def test_nested_bot_menu_keyboards_can_target_bot_interface_back(self): + subscription_markup = get_subscription_options_keyboard( + {1: 100}, + "RUB", + "en", + self.i18n, + back_callback="main_action:bot_interface", + ) + referral_markup = get_referral_link_keyboard( + "en", + self.i18n, + back_callback="main_action:bot_interface", + ) + info_markup = get_information_links_keyboard( + "en", + self.i18n, + "https://example.com/privacy", + "https://example.com/agreement", + back_callback="main_action:bot_interface", + ) + language_markup = get_language_selection_keyboard( + self.i18n, + "en", + back_callback="main_action:bot_interface", + ) + + self.assertIn("main_action:bot_interface", self._callback_data(subscription_markup)) + self.assertIn("main_action:bot_interface", self._callback_data(referral_markup)) + self.assertIn("main_action:bot_interface", self._callback_data(info_markup)) + self.assertIn("set_lang_ru:bot", self._callback_data(language_markup)) + + def test_webapp_referral_link_uses_ref_query_and_is_normalized(self): + link = referral._build_webapp_referral_link( + "https://app.example.com/invite?utm=channel", + "AbC123xYz", + ) + + self.assertEqual( + link, + "https://app.example.com/invite?utm=channel&ref=uAbC123xYz", + ) + self.assertEqual(subscription_webapp._normalize_referral_param("uAbC123xYz"), "ABC123XYZ") + self.assertEqual(subscription_webapp._normalize_referral_param("ref_uAbC123xYz"), "ABC123XYZ") + + def test_referral_text_places_web_link_after_telegram_link(self): + text = self.i18n.gettext( + "en", + "referral_program_info_new", + referral_link="https://t.me/bot?start=ref_uABC123XYZ", + webapp_link_section=self.i18n.gettext( + "en", + "referral_webapp_link_line", + webapp_referral_link="https://app.example.com/?ref=uABC123XYZ", + ), + bonus_details="bonus", + invited_count=1, + purchased_count=0, + ) + + self.assertLess(text.index("Telegram link:"), text.index("Web link:")) + self.assertLess(text.index("Web link:"), text.index("Invitation bonuses:"))