From a4581663f4eb550574f7c2f9c0c2be3453d6129b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Jun 2026 22:40:52 +0200 Subject: [PATCH] feat(telegram): link the promo body @username to the Mini App deep link The promo message body now renders "@" as an HTML text_link to the same ?startapp deep link the button uses (ParseMode HTML), so tapping the mention opens the seeded Mini App instead of the bot profile. Same payload (campaign start-param, else the forwarded /start payload) backs both the button and the mention. --- .../telegram/internal/promobot/promobot.go | 30 ++++++++++++------- .../internal/promobot/promobot_test.go | 23 ++++++++------ 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/platform/telegram/internal/promobot/promobot.go b/platform/telegram/internal/promobot/promobot.go index 821e6dd..2109a0a 100644 --- a/platform/telegram/internal/promobot/promobot.go +++ b/platform/telegram/internal/promobot/promobot.go @@ -11,6 +11,7 @@ package promobot import ( "cmp" "context" + "html" "net/url" "strings" @@ -112,13 +113,16 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up if update.Message.From != nil { lang = update.Message.From.LanguageCode } - text, button := promoText(lang, t.username) + // The configured campaign payload (a variant-seed deep link) takes precedence; absent + // one, fall back to forwarding any /start payload the user arrived with. The same + // payload backs both the inline button and the @username link in the body. + param := cmp.Or(t.startParam, startPayload(update.Message.Text)) + text, button := promoText(lang, t.username, t.launchURL(param)) if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{ - ChatID: update.Message.Chat.ID, - Text: text, - // The configured campaign payload (a variant-seed deep link) takes precedence; - // absent one, fall back to forwarding any /start payload the user arrived with. - ReplyMarkup: t.launchMarkup(button, cmp.Or(t.startParam, startPayload(update.Message.Text))), + ChatID: update.Message.Chat.ID, + Text: text, + ParseMode: models.ParseModeHTML, + ReplyMarkup: t.launchMarkup(button, param), }); err != nil { t.log.Warn("promo: reply to start failed", zap.Error(err)) } @@ -169,11 +173,15 @@ func startPayload(text string) string { return strings.TrimSpace(strings.TrimPrefix(text, cmd)) } -// promoText returns the localized message body and button label, naming the main bot -// (Russian for a "ru" language code, English otherwise). -func promoText(lang, username string) (text, button string) { +// promoText returns the localized message body and button label. The body names the main +// bot as a clickable @username whose link is the Mini App deep link (launchURL, the same +// target as the button), so tapping the mention opens the seeded Mini App rather than the +// bot profile. The body is sent with ParseMode HTML; only the link carries markup, so the +// static sentences need no escaping. Russian for a "ru" language code, English otherwise. +func promoText(lang, username, launchURL string) (text, button string) { + mention := `@` + html.EscapeString(username) + `` if strings.HasPrefix(strings.ToLower(lang), "ru") { - return "Откройте @" + username + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!" + return "Откройте " + mention + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!" } - return "Open @" + username + " and choose your game variant in the profile settings.", "🤩 I want to play!" + return "Open " + mention + " and choose your game variant in the profile settings.", "🤩 I want to play!" } diff --git a/platform/telegram/internal/promobot/promobot_test.go b/platform/telegram/internal/promobot/promobot_test.go index 4786ed9..4e719d4 100644 --- a/platform/telegram/internal/promobot/promobot_test.go +++ b/platform/telegram/internal/promobot/promobot_test.go @@ -13,25 +13,30 @@ import ( ) func TestPromoTextLocalization(t *testing.T) { - en, enBtn := promoText("en", "ScrabbleBot") - if !strings.Contains(en, "@ScrabbleBot") || !strings.Contains(en, "profile settings") { + const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en" + // The @username is rendered as a clickable deep link (the same target as the button), + // not a plain mention, so tapping it opens the seeded Mini App. + wantLink := `@ScrabbleBot` + + en, enBtn := promoText("en", "ScrabbleBot", url) + if !strings.Contains(en, wantLink) || !strings.Contains(en, "profile settings") { t.Errorf("en text = %q", en) } if enBtn != "🤩 I want to play!" { t.Errorf("en button = %q", enBtn) } - ru, ruBtn := promoText("ru-RU", "ScrabbleBot") - if !strings.Contains(ru, "@ScrabbleBot") || !strings.Contains(ru, "Откройте") { + ru, ruBtn := promoText("ru-RU", "ScrabbleBot", url) + if !strings.Contains(ru, wantLink) || !strings.Contains(ru, "Откройте") { t.Errorf("ru text = %q", ru) } if ruBtn != "🤩 Хочу играть!" { t.Errorf("ru button = %q", ruBtn) } - // An unknown language falls back to English. - if got, _ := promoText("de", "B"); !strings.Contains(got, "Open @B") { - t.Errorf("fallback text = %q, want English", got) + // An unknown language falls back to English, still with the linked mention. + if got, _ := promoText("de", "B", url); !strings.Contains(got, "Open ") || !strings.Contains(got, `">@B`) { + t.Errorf("fallback text = %q, want English with a linked mention", got) } } @@ -93,8 +98,8 @@ func TestHandleStartReplies(t *testing.T) { if api.chatID != "42" { t.Errorf("chat_id = %q, want 42", api.chatID) } - if !strings.Contains(api.text, "@ScrabbleBot") { - t.Errorf("text = %q, want the @mention", api.text) + if !strings.Contains(api.text, `@ScrabbleBot`) { + t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text) } if strings.Contains(api.replyMarkup, "web_app") { t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup)