chore: improve telegram bot menu navigation and referrals

This commit is contained in:
3252a8
2026-05-01 22:16:00 +03:00
parent 4de730bf5a
commit 81f5168726
9 changed files with 370 additions and 59 deletions
+3 -1
View File
@@ -315,7 +315,9 @@ Web App запускается в том же контейнере, что и б
docker compose up -d --build 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/<bot>?start=ref_u<code>` и Web App ссылка с query-параметром `ref=u<code>`. Web App учитывает `ref`, `start`, `start_param` и Telegram Mini Apps `start_param`, сохраняет найденный параметр до авторизации и передаёт его в Telegram OAuth и email-вход, чтобы регистрация корректно привязалась к пригласившему.
Для email-регистраций пользователь в панели Remnawave создается с анонимным username вида `em_<referral_code>`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_<telegram_id>`. Для email-регистраций пользователь в панели Remnawave создается с анонимным username вида `em_<referral_code>`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_<telegram_id>`.
+12 -3
View File
@@ -31,7 +31,8 @@ MAX_PROMO_CODE_INPUT_LENGTH = 100
async def prompt_promo_code_input(callback: types.CallbackQuery, async def prompt_promo_code_input(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict, 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) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n: if not i18n:
@@ -52,14 +53,22 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
try: try:
await callback.message.edit_text( await callback.message.edit_text(
text=_(key="promo_code_prompt"), 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: except Exception as e_edit:
logging.warning( logging.warning(
f"Failed to edit message for promo prompt: {e_edit}. Sending new one." f"Failed to edit message for promo prompt: {e_edit}. Sending new one."
) )
await callback.message.answer( await callback.message.answer(
text=_(key="promo_code_prompt"), 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 safe_answer_callback(callback)
await state.set_state(UserPromoStates.waiting_for_promo_code) await state.set_state(UserPromoStates.waiting_for_promo_code)
+64 -23
View File
@@ -9,17 +9,16 @@ from config.settings import Settings
from db.dal import user_dal from db.dal import user_dal
from bot.services.referral_service import ReferralService 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 from bot.middlewares.i18n import JsonI18n
router = Router(name="user_referral_router") router = Router(name="user_referral_router")
async def referral_command_handler(event: Union[types.Message, async def referral_command_handler(event: Union[types.Message, types.CallbackQuery],
types.CallbackQuery],
settings: Settings, i18n_data: dict, settings: Settings, i18n_data: dict,
referral_service: ReferralService, bot: Bot, 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) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") 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 _( bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
"referral_no_bonuses_configured") "referral_no_bonuses_configured")
# Get referral statistics
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id) 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", text = _("referral_program_info_new",
referral_link=referral_link, referral_link=referral_link,
webapp_link_section=webapp_link_section,
bonus_details=bonus_details_str, bonus_details=bonus_details_str,
invited_count=referral_stats["invited_count"], invited_count=referral_stats["invited_count"],
purchased_count=referral_stats["purchased_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<code>{webapp_referral_link}</code>"
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard 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): if isinstance(event, types.Message):
await event.answer(text, 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_info = await bot.get_me()
bot_username = bot_info.username bot_username = bot_info.username
if not bot_username: if not bot_username:
await callback.answer("Ошибка получения имени бота", show_alert=True) await callback.answer(_("error_generating_referral_link"), show_alert=True)
return return
inviter_user_id = callback.from_user.id 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) await callback.answer(_("error_generating_referral_link"), show_alert=True)
return 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( await callback.message.answer(
friend_message, friend_message,
disable_web_page_preview=True disable_web_page_preview=True
) )
except Exception as e: except Exception as e:
logging.error(f"Error in referral share message: {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() await callback.answer()
@@ -196,3 +215,25 @@ def _build_webapp_referral_link(base_url: Optional[str], referral_code: Optional
parts.fragment, 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)
+88 -9
View File
@@ -152,6 +152,11 @@ async def send_bot_interface_menu(
settings, subscription_service, session, user_id) settings, subscription_service, session, user_id)
text = i18n.gettext(current_lang, "bot_interface_menu_title") 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( reply_markup = get_bot_interface_inline_keyboard(
current_lang, i18n, settings, show_trial_button_in_menu) current_lang, i18n, settings, show_trial_button_in_menu)
@@ -797,6 +802,7 @@ async def language_command_handler(
event: Union[types.Message, types.CallbackQuery], event: Union[types.Message, types.CallbackQuery],
i18n_data: dict, i18n_data: dict,
settings: Settings, settings: Settings,
back_callback: str = "main_action:back_to_main",
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -804,7 +810,11 @@ async def language_command_handler(
) if i18n else key ) if i18n else key
text_to_send = _(key="choose_language") 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( target_message_obj = event.message if isinstance(
event, types.CallbackQuery) else event event, types.CallbackQuery) else event
@@ -845,7 +855,8 @@ async def select_language_callback_handler(
return return
try: try:
lang_code = callback.data.split("_")[2] lang_payload = callback.data.split("_", 2)[2]
lang_code, _, return_target = lang_payload.partition(":")
except IndexError: except IndexError:
await safe_answer_callback( await safe_answer_callback(
callback, callback,
@@ -879,12 +890,20 @@ async def select_language_callback_handler(
exc_info=True) exc_info=True)
await safe_answer_callback(callback, "Error setting language.", show_alert=True) await safe_answer_callback(callback, "Error setting language.", show_alert=True)
return return
await send_main_menu(callback, if return_target == "bot":
settings, await send_bot_interface_menu(callback,
i18n_data, settings,
subscription_service, i18n_data,
session, subscription_service,
is_edit=True) 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:")) @router.callback_query(F.data.startswith("main_action:"))
@@ -896,6 +915,9 @@ async def main_action_callback_handler(
action = callback.data.split(":")[1] action = callback.data.split(":")[1]
user_id = callback.from_user.id 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 subscription as user_subscription_handlers
from . import referral as user_referral_handlers from . import referral as user_referral_handlers
from . import promo_user as user_promo_handlers from . import promo_user as user_promo_handlers
@@ -912,10 +934,29 @@ async def main_action_callback_handler(
if action == "subscribe": if action == "subscribe":
await user_subscription_handlers.display_subscription_options( await user_subscription_handlers.display_subscription_options(
callback, i18n_data, settings, session) 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": elif action == "my_subscription":
await user_subscription_handlers.my_subscription_command_handler( await user_subscription_handlers.my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service, callback, i18n_data, settings, panel_service, subscription_service,
session, bot) 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": elif action == "my_devices":
await user_subscription_handlers.my_devices_command_handler( await user_subscription_handlers.my_devices_command_handler(
callback, i18n_data, settings, panel_service, subscription_service, callback, i18n_data, settings, panel_service, subscription_service,
@@ -923,16 +964,49 @@ async def main_action_callback_handler(
elif action == "referral": elif action == "referral":
await user_referral_handlers.referral_command_handler( await user_referral_handlers.referral_command_handler(
callback, settings, i18n_data, referral_service, bot, session) 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": elif action == "apply_promo":
await user_promo_handlers.prompt_promo_code_input( await user_promo_handlers.prompt_promo_code_input(
callback, state, i18n_data, settings, session) 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": elif action == "request_trial":
await user_trial_handlers.request_trial_confirmation_handler( await user_trial_handlers.request_trial_confirmation_handler(
callback, settings, i18n_data, subscription_service, session) callback, settings, i18n_data, subscription_service, session)
elif action == "language": elif action == "language":
await language_command_handler(callback, i18n_data, settings) 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") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", current_lang = i18n_data.get("current_language",
settings.DEFAULT_LANGUAGE) settings.DEFAULT_LANGUAGE)
@@ -962,6 +1036,11 @@ async def main_action_callback_handler(
i18n, i18n,
privacy_url, privacy_url,
user_agreement_url, user_agreement_url,
back_callback=(
"main_action:bot_interface"
if callback.data == "main_action:bot_info"
else "main_action:back_to_main"
),
) )
try: try:
await callback.message.edit_text(_(key="info_links_message"), await callback.message.edit_text(_(key="info_links_message"),
+29 -5
View File
@@ -39,7 +39,13 @@ def _hwid_callback_token(hwid: Optional[str]) -> str:
return hashlib.sha256(hwid_str.encode()).hexdigest()[:32] 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) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") 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: if options:
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period") text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
reply_markup = get_subscription_options_keyboard( 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: else:
text_content = get_text("no_subscription_options_available") 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 target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
if not target_message_obj: if not target_message_obj:
@@ -116,6 +131,7 @@ async def my_subscription_command_handler(
subscription_service: SubscriptionService, subscription_service: SubscriptionService,
session: AsyncSession, session: AsyncSession,
bot: Bot, bot: Bot,
back_callback: str = "main_action:back_to_main",
): ):
target = event.message if isinstance(event, types.CallbackQuery) else event target = event.message if isinstance(event, types.CallbackQuery) else event
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
@@ -139,7 +155,11 @@ async def my_subscription_command_handler(
buy_button = InlineKeyboardButton( buy_button = InlineKeyboardButton(
text=get_text("menu_subscribe_inline"), callback_data="main_action:subscribe" 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]) kb = InlineKeyboardMarkup(inline_keyboard=[[buy_button], *back_markup.inline_keyboard])
@@ -231,7 +251,11 @@ async def my_subscription_command_handler(
traffic_period=period_label, 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 kb = base_markup.inline_keyboard
try: try:
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id) local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
+35 -16
View File
@@ -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: if settings.SUPPORT_LINK:
builder.row( builder.row(
InlineKeyboardButton(text=_(key="menu_support_button"), InlineKeyboardButton(text=_(key="menu_support_button"),
url=settings.SUPPORT_LINK)) 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() return builder.as_markup()
@@ -59,26 +69,26 @@ def get_bot_interface_inline_keyboard(
builder.row( builder.row(
InlineKeyboardButton(text=_(key="menu_subscribe_inline"), InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
callback_data="main_action:subscribe")) callback_data="main_action:bot_subscribe"))
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=_(key="menu_my_subscription_inline"), text=_(key="menu_my_subscription_inline"),
callback_data="main_action:my_subscription", callback_data="main_action:bot_my_subscription",
) )
) )
referral_button = InlineKeyboardButton( referral_button = InlineKeyboardButton(
text=_(key="menu_referral_inline"), text=_(key="menu_referral_inline"),
callback_data="main_action:referral") callback_data="main_action:bot_referral")
promo_button = InlineKeyboardButton( promo_button = InlineKeyboardButton(
text=_(key="menu_apply_promo_button"), 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(referral_button)
builder.row(promo_button) builder.row(promo_button)
language_button = InlineKeyboardButton( language_button = InlineKeyboardButton(
text=_(key="menu_language_settings_inline"), text=_(key="menu_language_settings_inline"),
callback_data="main_action:language") callback_data="main_action:bot_language")
status_button_list = [] status_button_list = []
if settings.SERVER_STATUS_URL: if settings.SERVER_STATUS_URL:
status_button_list.append( status_button_list.append(
@@ -99,7 +109,11 @@ def get_bot_interface_inline_keyboard(
if settings.PRIVACY_POLICY_URL or user_agreement_url: if settings.PRIVACY_POLICY_URL or user_agreement_url:
builder.row( builder.row(
InlineKeyboardButton(text=_(key="menu_info_button"), 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() return builder.as_markup()
@@ -108,7 +122,8 @@ def get_information_links_keyboard(
lang: str, lang: str,
i18n_instance, i18n_instance,
privacy_policy_url: Optional[str], 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) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
if privacy_policy_url: if privacy_policy_url:
@@ -121,21 +136,23 @@ def get_information_links_keyboard(
url=user_agreement_url)) url=user_agreement_url))
builder.row( builder.row(
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main")) callback_data=back_callback))
return builder.as_markup() return builder.as_markup()
def get_language_selection_keyboard(i18n_instance, 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 _ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs
) )
callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else ""
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text=f"🇬🇧 English {'' if current_lang == 'en' else ''}", 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 ''}", 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"), builder.button(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main") callback_data=back_callback)
builder.adjust(1) builder.adjust(1)
return builder.as_markup() return builder.as_markup()
@@ -154,7 +171,8 @@ def get_trial_confirmation_keyboard(lang: str,
def get_subscription_options_keyboard(subscription_options: Dict[ def get_subscription_options_keyboard(subscription_options: Dict[
float, Optional[float]], currency_symbol_val: str, lang: str, 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) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
def _format_gb(val: float) -> str: def _format_gb(val: float) -> str:
@@ -181,7 +199,7 @@ def get_subscription_options_keyboard(subscription_options: Dict[
builder.adjust(1) builder.adjust(1)
builder.row( builder.row(
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main")) callback_data=back_callback))
return builder.as_markup() return builder.as_markup()
@@ -365,13 +383,14 @@ def get_yk_saved_cards_keyboard(
def get_referral_link_keyboard(lang: str, 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) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text=_(key="referral_share_message_button"), builder.button(text=_(key="referral_share_message_button"),
callback_data="referral_action:share_message") callback_data="referral_action:share_message")
builder.button(text=_(key="back_to_main_menu_button"), builder.button(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main") callback_data=back_callback)
builder.adjust(1) builder.adjust(1)
return builder.as_markup() return builder.as_markup()
+5 -1
View File
@@ -11,6 +11,7 @@
"menu_activate_trial_button": "🆓 Free Trial", "menu_activate_trial_button": "🆓 Free Trial",
"menu_subscribe_inline": "🚀 Purchase", "menu_subscribe_inline": "🚀 Purchase",
"menu_personal_account_button": "🔑 Personal Account", "menu_personal_account_button": "🔑 Personal Account",
"menu_bot_interface_button": "🤖 Bot menu",
"menu_my_subscription_inline": "🔐 My Subscription", "menu_my_subscription_inline": "🔐 My Subscription",
"no_subscription_options_available": "Subscription issuance is not configured by the bot administrator", "no_subscription_options_available": "Subscription issuance is not configured by the bot administrator",
"menu_referral_inline": "🎁 Invite friend", "menu_referral_inline": "🎁 Invite friend",
@@ -22,6 +23,7 @@
"menu_terms_button": "📄 Terms of Service", "menu_terms_button": "📄 Terms of Service",
"menu_info_button": "️ Information", "menu_info_button": "️ Information",
"bot_interface_menu_title": "Bot interface", "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:", "info_links_message": "Choose a document:",
"privacy_policy_button": "🔒 Privacy Policy", "privacy_policy_button": "🔒 Privacy Policy",
"user_agreement_button": "📄 User Agreement", "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}.", "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", "yes_button": "Yes",
"no_button": "No", "no_button": "No",
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!", "referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Telegram link:\n<code>{referral_link}</code>{webapp_link_section}\n\n💰 <b>Invitation bonuses:</b>\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: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>", "referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>",
"referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.", "referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.",
"referral_share_message_button": "📩 Message for friend", "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": "🚀 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<code>{webapp_referral_link}</code>",
"friend_placeholder": "friend", "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_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}.", "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}.",
+5 -1
View File
@@ -11,6 +11,7 @@
"menu_activate_trial_button": "🆓 Пробный период", "menu_activate_trial_button": "🆓 Пробный период",
"menu_subscribe_inline": "🚀 Купить", "menu_subscribe_inline": "🚀 Купить",
"menu_personal_account_button": "🔑 Личный кабинет", "menu_personal_account_button": "🔑 Личный кабинет",
"menu_bot_interface_button": "🤖 Бот-меню",
"menu_my_subscription_inline": "🔐 Моя подписка", "menu_my_subscription_inline": "🔐 Моя подписка",
"no_subscription_options_available": "Выдача подписки не настроена администратором бота", "no_subscription_options_available": "Выдача подписки не настроена администратором бота",
"menu_referral_inline": "🎁 Пригласить друга", "menu_referral_inline": "🎁 Пригласить друга",
@@ -22,6 +23,7 @@
"menu_terms_button": "📄 Условия сервиса", "menu_terms_button": "📄 Условия сервиса",
"menu_info_button": "ℹ️ Информация", "menu_info_button": "ℹ️ Информация",
"bot_interface_menu_title": "Интерфейс в боте", "bot_interface_menu_title": "Интерфейс в боте",
"bot_interface_menu_webapp_hint": "В личном кабинете взаимодействовать с сервисом будет удобнее.",
"info_links_message": "Выберите документ:", "info_links_message": "Выберите документ:",
"privacy_policy_button": "🔒 Политика конфиденциальности", "privacy_policy_button": "🔒 Политика конфиденциальности",
"user_agreement_button": "📄 Пользовательское соглашение", "user_agreement_button": "📄 Пользовательское соглашение",
@@ -100,11 +102,13 @@
"referral_welcome_bonus_applied": "🎁 Вам начислено {days} бонусных дн. за регистрацию по реферальной ссылке!\nПодписка активна до {end_date}.", "referral_welcome_bonus_applied": "🎁 Вам начислено {days} бонусных дн. за регистрацию по реферальной ссылке!\nПодписка активна до {end_date}.",
"yes_button": "Да", "yes_button": "Да",
"no_button": "Нет", "no_button": "Нет",
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!", "referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Telegram ссылка:\n<code>{referral_link}</code>{webapp_link_section}\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>", "referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
"referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.", "referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.",
"referral_share_message_button": "📩 Сообщение для друга", "referral_share_message_button": "📩 Сообщение для друга",
"referral_friend_message": "🚀 Привет! Попробуй этот сервис - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", "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<code>{webapp_referral_link}</code>",
"friend_placeholder": "друг", "friend_placeholder": "друг",
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.", "referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.", "referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
+129
View File
@@ -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:"))