From f8fab4a4c2399594bc65c3b5971ba8e6a2561389 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Jun 2026 21:51:52 +0200 Subject: [PATCH 1/5] fix(ui): rename Friends "Share via Telegram" to "Share" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The share button uses the OS Web Share sheet outside Telegram (the TG share picker only inside it), so "via Telegram" was misleading. Rename to a neutral "Share"/"Поделиться" and drop the now-unused tgshare class. --- ui/src/lib/i18n/en.ts | 2 +- ui/src/lib/i18n/ru.ts | 2 +- ui/src/screens/Friends.svelte | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 2b83d99..07544c3 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -247,7 +247,7 @@ export const en = { 'friends.redeem': 'Add', 'friends.copy': 'Copy', 'friends.codeCopied': 'Code copied.', - 'friends.shareTelegram': 'Share via Telegram', + 'friends.shareTelegram': 'Share', 'friends.inviteText': "Let's play Scrabble!", 'friends.linkCopied': 'Link copied.', 'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️", diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 31a8c05..bbfe5bf 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -248,7 +248,7 @@ export const ru: Record = { 'friends.redeem': 'Добавить', 'friends.copy': 'Копировать', 'friends.codeCopied': 'Код скопирован.', - 'friends.shareTelegram': 'Поделиться через Telegram', + 'friends.shareTelegram': 'Поделиться', 'friends.inviteText': 'Давай играть в Эрудит!', 'friends.linkCopied': 'Ссылка скопирована.', 'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️', diff --git a/ui/src/screens/Friends.svelte b/ui/src/screens/Friends.svelte index b4cb81d..66f8a11 100644 --- a/ui/src/screens/Friends.svelte +++ b/ui/src/screens/Friends.svelte @@ -182,7 +182,7 @@ {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} {#if tg} - + {/if} {:else} From c02262fcf7e65070852d93a01c16d14511a1bd79 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Jun 2026 21:51:52 +0200 Subject: [PATCH 2/5] style(ui): halve custom header top/bottom padding The custom title bar was too tall. Halve the standard bar padding (10->5px) and, in the Telegram path, the notch gap (16->8px) and bottom (6->3px); the notch safe-area inset is unchanged. Title and back chevron stay vertically centred. --- ui/src/components/Header.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index 6579946..7d79b29 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -81,7 +81,7 @@ display: flex; align-items: center; gap: var(--gap); - padding: 10px var(--pad); + padding: 5px var(--pad); } h1 { font-size: 1.05rem; @@ -139,15 +139,15 @@ back chevron is hidden), so min-height (the nav-band height) doesn't bind. Without the (removed) hamburger that content shrank and the bar sat flush under Telegram's native nav band. So **padding-top** is the lever: it drops the title clear of the band — the notch - plus a **10px** gap (was 6). A fixed px (not rem/em) gap so the clearance from Telegram's - native controls stays constant if the user scales up the font (the title then grows - downward and the bar with it). (Owner-tunable: the 10px.) */ + plus an **8px** gap (halved from 16). A fixed px (not rem/em) gap so the clearance from + Telegram's native controls stays constant if the user scales up the font (the title then + grows downward and the bar with it). (Owner-tunable: the 8px.) */ min-height: var(--tg-content-top); box-sizing: border-box; align-items: center; justify-content: center; - padding-top: calc(var(--tg-safe-top) + 16px); - padding-bottom: 6px; + padding-top: calc(var(--tg-safe-top) + 8px); + padding-bottom: 3px; } :global(html.tg-fullscreen) .spacer { display: none; From 03dfc29a5489060542695ec8343e8fd66ec33819 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Jun 2026 21:51:52 +0200 Subject: [PATCH 3/5] feat(telegram): promo deep-link seeds English Scrabble for new users The promo bot button carries a configurable variant-seed start-param (default verudit_ru-scrabble_en). The gateway parses start_param from the validated initData and forwards it; the backend, on first contact only, seeds the new account variant_preferences from it (English Scrabble alongside the default Erudit). No schema change (the scrabble_en CHECK is already in the baseline) and the gateway<->backend REST field is additive, so the rolling deploy is safe in either order. TELEGRAM_PROMO_START_PARAM configures the payload (empty forwards the user own /start payload). Covered by account unit tests, a gateway transcode test, and an integration test asserting new-only seeding. --- backend/internal/account/profile.go | 60 ++++++++++++++ backend/internal/account/variant_seed_test.go | 37 +++++++++ .../internal/inttest/promo_variant_test.go | 80 +++++++++++++++++++ backend/internal/server/handlers_auth.go | 16 +++- deploy/.env.example | 1 + deploy/docker-compose.bot.yml | 1 + docs/ARCHITECTURE.md | 2 +- docs/FUNCTIONAL.md | 5 +- docs/FUNCTIONAL_ru.md | 5 +- gateway/internal/backendclient/api.go | 9 ++- gateway/internal/transcode/transcode.go | 13 ++- .../transcode/transcode_telegram_test.go | 7 +- platform/telegram/README.md | 6 +- platform/telegram/cmd/bot/main.go | 1 + platform/telegram/internal/config/config.go | 11 +++ .../telegram/internal/promobot/promobot.go | 30 ++++--- 16 files changed, 260 insertions(+), 24 deletions(-) create mode 100644 backend/internal/account/variant_seed_test.go create mode 100644 backend/internal/inttest/promo_variant_test.go diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go index fbee7a7..476adac 100644 --- a/backend/internal/account/profile.go +++ b/backend/internal/account/profile.go @@ -101,6 +101,66 @@ func validateVariantPreferences(prefs []string) ([]string, error) { return out, nil } +// variantSeedPrefix marks a Telegram start-param payload that seeds a brand-new +// account's variant preferences (e.g. "verudit_ru-scrabble_en"): the prefix, then the +// canonical variant labels joined by "-". It is deliberately distinct from the routing +// deep links (g/i/f; see platform/telegram .../deeplink) so the client's start-param +// router falls through to the lobby for it. +const variantSeedPrefix = "v" + +// SeedVariantsFromStartParam decodes a promo deep-link start-param into the variant +// preference set to seed onto a brand-new account: the variantSeedPrefix followed by +// the canonical variant labels joined by "-" (e.g. "verudit_ru-scrabble_en"). It +// returns nil for any payload that is not a variant-seed link or that fails validation +// against the known variants, so a malformed, empty or unrelated start-param simply +// leaves the account on its default preferences rather than failing the login. +func SeedVariantsFromStartParam(startParam string) []string { + if !strings.HasPrefix(startParam, variantSeedPrefix) { + return nil + } + body := strings.TrimPrefix(startParam, variantSeedPrefix) + if body == "" { + return nil + } + prefs, err := validateVariantPreferences(strings.Split(body, "-")) + if err != nil { + return nil + } + return prefs +} + +// SetVariantPreferences overwrites only the variant-preference set of the account, +// cleaning it to a deduplicated, canonically ordered subset of the known variants +// (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it +// reports ErrNotFound when no account matches id. It is the narrow counterpart to +// UpdateProfile used to seed a promo-onboarded account's variants at first contact +// without disturbing its other profile fields. +func (s *Store) SetVariantPreferences(ctx context.Context, id uuid.UUID, prefs []string) (Account, error) { + clean, err := validateVariantPreferences(prefs) + if err != nil { + return Account{}, err + } + stmt := table.Accounts.UPDATE( + table.Accounts.VariantPreferences, table.Accounts.UpdatedAt, + ).SET( + // clean is validated against the closed knownVariants set; bind as a text[] + // parameter (lib/pq encodes the array, the cast pins the column type), mirroring + // UpdateProfile. + postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(clean)}), + postgres.TimestampzT(time.Now().UTC()), + ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))). + RETURNING(table.Accounts.AllColumns) + + var row model.Accounts + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Account{}, ErrNotFound + } + return Account{}, fmt.Errorf("account: set variant preferences %s: %w", id, err) + } + return modelToAccount(row), nil +} + // UpdateProfile validates and overwrites the editable fields of the account, then // returns the stored row. It reports ErrInvalidProfile for a bad language, // timezone or display name and ErrNotFound when no account matches id. diff --git a/backend/internal/account/variant_seed_test.go b/backend/internal/account/variant_seed_test.go new file mode 100644 index 0000000..3977997 --- /dev/null +++ b/backend/internal/account/variant_seed_test.go @@ -0,0 +1,37 @@ +package account + +import ( + "slices" + "testing" +) + +// TestSeedVariantsFromStartParam covers decoding a promo deep-link start-param into the +// variant-preference set to seed: a valid "v"-prefixed, "-"-joined label list is cleaned +// to the canonical order and deduplicated, while anything that is not a variant-seed link +// or that names an unknown variant yields nil (leaving the account on its defaults). +func TestSeedVariantsFromStartParam(t *testing.T) { + tests := []struct { + name string + param string + want []string + }{ + {"english promo", "verudit_ru-scrabble_en", []string{"erudit_ru", "scrabble_en"}}, + {"single variant", "vscrabble_en", []string{"scrabble_en"}}, + {"canonical order regardless of payload order", "vscrabble_en-erudit_ru", []string{"erudit_ru", "scrabble_en"}}, + {"deduplicated", "verudit_ru-erudit_ru", []string{"erudit_ru"}}, + {"empty", "", nil}, + {"prefix only", "v", nil}, + {"routing game link is not a seed", "g0190abcd", nil}, + {"friend code link is not a seed", "f123456", nil}, + {"unknown variant rejected", "vscrabble_de", nil}, + {"one unknown label rejects the whole set", "verudit_ru-scrabble_de", nil}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := SeedVariantsFromStartParam(tc.param) + if !slices.Equal(got, tc.want) { + t.Errorf("SeedVariantsFromStartParam(%q) = %v, want %v", tc.param, got, tc.want) + } + }) + } +} diff --git a/backend/internal/inttest/promo_variant_test.go b/backend/internal/inttest/promo_variant_test.go new file mode 100644 index 0000000..c25f475 --- /dev/null +++ b/backend/internal/inttest/promo_variant_test.go @@ -0,0 +1,80 @@ +//go:build integration + +package inttest + +import ( + "context" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + "github.com/google/uuid" + "go.uber.org/zap/zaptest" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/server" + "scrabble/backend/internal/session" +) + +// TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint +// to confirm a promo deep-link start-param seeds a brand-new account's variant +// preferences (English Scrabble alongside the default Erudit), that a new account with no +// such payload keeps the Erudit-only default, and that an existing account is never +// re-seeded on a later login (the new-user-only contract). +func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) { + srv := server.New(":0", server.Deps{ + Logger: zaptest.NewLogger(t), + DB: testDB, + Accounts: account.NewStore(testDB), + Sessions: session.NewService(session.NewStore(testDB), session.NewCache()), + Notifier: &captureNotifier{}, + }) + h := srv.Handler() + + post := func(ext, startParam string) { + body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"` + if startParam != "" { + body += `,"start_param":"` + startParam + `"` + } + body += `}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("telegram auth = %d: %s", rec.Code, rec.Body.String()) + } + } + store := account.NewStore(testDB) + reload := func(ext string) []string { + acc, err := store.AccountByIdentity(context.Background(), account.KindTelegram, ext) + if err != nil { + t.Fatalf("lookup %s: %v", ext, err) + } + return acc.VariantPreferences + } + + // A brand-new account reached through a promo deep-link is seeded with English + // Scrabble alongside the default Erudit. + promoExt := "tg-" + uuid.NewString() + post(promoExt, "verudit_ru-scrabble_en") + if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) { + t.Errorf("promo new account variants = %v, want %v", got, want) + } + + // A brand-new account with no promo payload keeps the Erudit-only default. + plainExt := "tg-" + uuid.NewString() + post(plainExt, "") + if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) { + t.Errorf("plain new account variants = %v, want %v", got, want) + } + + // A later login of the promo account, even via a different payload, must not re-seed: + // the seed is first-contact only. + post(promoExt, "vscrabble_ru") + if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) { + t.Errorf("existing account re-seeded = %v, want unchanged %v", got, want) + } +} diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index 9074fb4..4d23524 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "go.uber.org/zap" "scrabble/backend/internal/account" ) @@ -19,13 +20,16 @@ import ( // telegramAuthRequest carries the identity the connector extracted from a // validated initData payload. Username, FirstName and LanguageCode seed a // brand-new account's display name and language; BrowserTZ (the client's detected -// "±HH:MM" UTC offset) seeds its time zone (first contact only). +// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch +// deep-link payload, which may seed the new account's variant preferences (first +// contact only). type telegramAuthRequest struct { ExternalID string `json:"external_id"` Username string `json:"username"` FirstName string `json:"first_name"` LanguageCode string `json:"language_code"` BrowserTZ string `json:"browser_tz"` + StartParam string `json:"start_param"` } // handleTelegramAuth provisions (or finds) the account bound to a Telegram @@ -47,6 +51,16 @@ func (s *Server) handleTelegramAuth(c *gin.Context) { // joined the chat before registering is granted on the spot (no chat_member // event fires on registration). s.publishChatAccessChange(acc.ID) + // A promo deep-link may seed this brand-new account's variant preferences (e.g. + // English Scrabble alongside the default Erudit). Best-effort: an absent or + // malformed payload leaves the account on its defaults, and a write failure must + // not block the session mint. + if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 { + if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil { + s.log.Warn("telegram: seed variant preferences failed", + zap.String("account", acc.ID.String()), zap.Error(err)) + } + } } s.mintSession(c, acc) } diff --git a/deploy/.env.example b/deploy/.env.example index 2d8f21d..d33d8ee 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -51,6 +51,7 @@ TELEGRAM_SUPPORT_CHAT_ID= # private forum supergroup for the suppor TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; empty disables it TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set +TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload TELEGRAM_MINIAPP_URL= # required TELEGRAM_TEST_ENV=false TELEGRAM_API_BASE_URL= diff --git a/deploy/docker-compose.bot.yml b/deploy/docker-compose.bot.yml index 72563da..46a04f2 100644 --- a/deploy/docker-compose.bot.yml +++ b/deploy/docker-compose.bot.yml @@ -31,6 +31,7 @@ services: TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-} TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-} + TELEGRAM_PROMO_START_PARAM: ${TELEGRAM_PROMO_START_PARAM:-} TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL} # Real Bot API in prod (the test contour pins TELEGRAM_TEST_ENV=true instead). TELEGRAM_TEST_ENV: "false" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5152d10..0079a17 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -204,7 +204,7 @@ arrive from a platform rather than completing a mandatory registration). > recipient's interface language (`preferred_language`), with no per-bot routing. New > Game variant gating moved off the login language onto a per-user profile setting > `variant_preferences` (default Erudit only, server-enforced on the caller's create -> paths; an invited friend may still accept any variant). The per-bot env vars and +> paths; an invited friend may still accept any variant, and a Telegram **promo deep-link** seeds extra variants — e.g. English Scrabble — onto a brand-new account via the validated `start_param`). The per-bot env vars and > `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped > `service_language`/`supported_languages` and the push `language` routing field, and > gained `variant_preferences` on Profile/UpdateProfile. diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 12d1fa8..c21c38e 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -253,8 +253,9 @@ merge". **Preferences (which variants you can be matched into).** A profile setting picks the game variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow -yourself to be matched into; a **new account starts with Erudite only**, and you must keep **at -least one** selected. This list is exactly what **New Game** offers when you start a game +yourself to be matched into; a **new account starts with Erudite only** — unless it was created +through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep +**at least one** selected. This list is exactly what **New Game** offers when you start a game (auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is not offered, and the server refuses it. It does not restrict games you are **invited** to: an invited friend may accept an invitation in **any** variant, and you can always open and play diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 8b6e7fb..c611b7a 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -259,8 +259,9 @@ UTC; при создании аккаунта она подставляется **Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в -которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом**, и нужно -оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты +которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только +он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, — +и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя **приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте, diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 62e4951..dbc166e 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -184,10 +184,10 @@ type ChatResp struct { } // TelegramAuth provisions/finds the Telegram account and mints a session, seeding a -// brand-new account's display name and language from the validated launch fields and -// its time zone from browserTz (the client's detected "±HH:MM" UTC offset; first -// contact only). -func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz string) (SessionResp, error) { +// brand-new account's display name and language from the validated launch fields, its +// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the +// validated launch deep-link startParam, its variant preferences (first contact only). +func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam string) (SessionResp, error) { var out SessionResp err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "", map[string]string{ @@ -196,6 +196,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use "username": username, "first_name": firstName, "browser_tz": browserTz, + "start_param": startParam, }, &out) return out, err } diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 5c04035..b08acbf 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -9,6 +9,7 @@ import ( "context" "encoding/json" "errors" + "net/url" "scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/connector" @@ -154,11 +155,19 @@ func DomainCode(err error) (string, bool) { func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0) - user, err := tg.ValidateInitData(ctx, string(in.InitData())) + initData := string(in.InitData()) + user, err := tg.ValidateInitData(ctx, initData) if err != nil { return nil, err } - sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz())) + // start_param rides inside the signed initData validated just above, so the launch + // deep-link payload can be trusted here without a separate wire field; the backend + // uses it to seed a brand-new account's variant preferences. + startParam := "" + if q, perr := url.ParseQuery(initData); perr == nil { + startParam = q.Get("start_param") + } + sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam) if err != nil { return nil, err } diff --git a/gateway/internal/transcode/transcode_telegram_test.go b/gateway/internal/transcode/transcode_telegram_test.go index 2d02015..fd9ef4a 100644 --- a/gateway/internal/transcode/transcode_telegram_test.go +++ b/gateway/internal/transcode/transcode_telegram_test.go @@ -54,7 +54,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) { t.Fatal("auth.telegram not registered") } - payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("init")}) + payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("start_param=verudit_ru-scrabble_en&query_id=abc")}) if err != nil { t.Fatalf("handler: %v", err) } @@ -66,6 +66,11 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) { if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" { t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван", gotBody) } + // start_param is parsed out of the validated initData and forwarded so the backend can + // seed the new account's variant preferences from a promo deep link. + if gotBody["start_param"] != "verudit_ru-scrabble_en" { + t.Errorf("forwarded start_param = %q, want verudit_ru-scrabble_en", gotBody["start_param"]) + } } func TestTelegramAuthInvalidInitData(t *testing.T) { diff --git a/platform/telegram/README.md b/platform/telegram/README.md index 6f93ccd..53bab55 100644 --- a/platform/telegram/README.md +++ b/platform/telegram/README.md @@ -83,7 +83,10 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC the main bot's direct link (`TELEGRAM_BOT_LINK`, the same link the UI uses) with `?startapp` — a `web_app` button would launch under the promo bot's identity (its token would sign the initData), which the main bot's validator rejects. It is fully - self-contained: no bot-link, no gateway, no game. + self-contained: no bot-link, no gateway, no game. Its `?startapp` payload + (`TELEGRAM_PROMO_START_PARAM`, default `verudit_ru-scrabble_en`) is a variant-seed deep + link the backend decodes to seed a brand-new user's variant preferences (English Scrabble + alongside the default Erudit). - **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`, default 25) to respect the Bot API flood limits. @@ -151,6 +154,7 @@ Bot (`cmd/bot`): | `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it | | `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs | | `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs | +| `TELEGRAM_PROMO_START_PARAM` | `verudit_ru-scrabble_en` | the promo button's `startapp` payload — a variant-seed deep link the backend decodes to seed a brand-new user's variant preferences; empty forwards the user's own `/start` payload | | `TELEGRAM_OWNS_UPDATES` | `true` | run the exclusive `getUpdates` long-poll (one bot per token) | | `TELEGRAM_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) | | `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway | diff --git a/platform/telegram/cmd/bot/main.go b/platform/telegram/cmd/bot/main.go index 0ee1905..41a4853 100644 --- a/platform/telegram/cmd/bot/main.go +++ b/platform/telegram/cmd/bot/main.go @@ -131,6 +131,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error { TestEnv: cfg.TestEnv, BotUsername: cfg.BotUsername, BotLinkURL: cfg.BotLinkURL, + StartParam: cfg.PromoStartParam, SendRatePerSecond: cfg.SendRatePerSecond, }, logger) if err != nil { diff --git a/platform/telegram/internal/config/config.go b/platform/telegram/internal/config/config.go index 8d92ca0..da961b9 100644 --- a/platform/telegram/internal/config/config.go +++ b/platform/telegram/internal/config/config.go @@ -65,6 +65,12 @@ type BotConfig struct { // button appends ?startapp= to it (TELEGRAM_BOT_LINK; required when the // promo bot runs). It is distinct from the BotLink mTLS dial config below. BotLinkURL string + // PromoStartParam is the promo button's launch payload, appended as + // ?startapp= (TELEGRAM_PROMO_START_PARAM, default + // "verudit_ru-scrabble_en"). It is a variant-seed deep link the backend decodes to + // seed a brand-new user's variant preferences; its labels must match the backend's + // known variants. Empty falls back to forwarding the user's own /start payload. + PromoStartParam string // MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is // the base of every launch button (TELEGRAM_MINIAPP_URL, required). MiniAppURL string @@ -113,6 +119,10 @@ const ( defaultValidatorGRPCAddr = ":9091" defaultBotReconnectDelay = 2 * time.Second defaultSendRatePerSecond = 25 + // defaultPromoStartParam is the promo button's default launch payload: a variant-seed + // deep link the backend decodes to add English Scrabble to a brand-new user's variant + // preferences alongside the default Erudit. Override with TELEGRAM_PROMO_START_PARAM. + defaultPromoStartParam = "verudit_ru-scrabble_en" ) // LoadValidator reads the validator configuration from the environment. @@ -145,6 +155,7 @@ func LoadBot() (BotConfig, error) { PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"), BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"), BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"), + PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam), SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"), LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"), BotLink: BotLinkClientConfig{ diff --git a/platform/telegram/internal/promobot/promobot.go b/platform/telegram/internal/promobot/promobot.go index 4fe20a6..821e6dd 100644 --- a/platform/telegram/internal/promobot/promobot.go +++ b/platform/telegram/internal/promobot/promobot.go @@ -9,6 +9,7 @@ package promobot import ( + "cmp" "context" "net/url" "strings" @@ -33,6 +34,11 @@ type Config struct { // BotLinkURL is the main bot's Mini App direct link; the button appends // ?startapp= to it. BotLinkURL string + // StartParam is the campaign payload appended as ?startapp= to the launch + // button — e.g. a variant-seed deep link ("verudit_ru-scrabble_en") the backend + // decodes to seed a brand-new user's variant preferences. Empty falls back to + // forwarding the user's own /start payload. + StartParam string // SendRatePerSecond caps outbound sends to respect the Bot API flood limits; 0 // disables the limiter. The burst equals the per-second rate. SendRatePerSecond int @@ -40,11 +46,12 @@ type Config struct { // Bot is the promo bot wrapper around a Telegram Bot API client. type Bot struct { - api *tgbot.Bot - username string - linkURL string - log *zap.Logger - limiter *rate.Limiter + api *tgbot.Bot + username string + linkURL string + startParam string + log *zap.Logger + limiter *rate.Limiter } // New builds the promo bot, registering a /start (and default) handler that replies @@ -53,7 +60,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) { if log == nil { log = zap.NewNop() } - t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, log: log} + t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, startParam: cfg.StartParam, log: log} if cfg.SendRatePerSecond > 0 { t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond) } @@ -87,7 +94,8 @@ func (t *Bot) Run(ctx context.Context) { } // handleStart replies to any message (typically /start) with the localized promo text -// and a button that opens the main bot's Mini App, forwarding any /start payload. +// and a button that opens the main bot's Mini App at the configured campaign payload +// (falling back to forwarding any /start payload the user arrived with). func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) { if update.Message == nil { return @@ -106,9 +114,11 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up } text, button := promoText(lang, t.username) if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{ - ChatID: update.Message.Chat.ID, - Text: text, - ReplyMarkup: t.launchMarkup(button, startPayload(update.Message.Text)), + 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))), }); err != nil { t.log.Warn("promo: reply to start failed", zap.Error(err)) } From a4581663f4eb550574f7c2f9c0c2be3453d6129b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Jun 2026 22:40:52 +0200 Subject: [PATCH 4/5] 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) From 9207664fbd65ecc55f38da9fc33e1e429a6cc157 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Jun 2026 22:45:54 +0200 Subject: [PATCH 5/5] docs(telegram): note the promo body @username is a deep link --- platform/telegram/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/platform/telegram/README.md b/platform/telegram/README.md index 53bab55..13ea0b0 100644 --- a/platform/telegram/README.md +++ b/platform/telegram/README.md @@ -86,7 +86,9 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC self-contained: no bot-link, no gateway, no game. Its `?startapp` payload (`TELEGRAM_PROMO_START_PARAM`, default `verudit_ru-scrabble_en`) is a variant-seed deep link the backend decodes to seed a brand-new user's variant preferences (English Scrabble - alongside the default Erudit). + alongside the default Erudit). The message body also renders the bot's `@username` as that + same deep link (an HTML `text_link`), so tapping the mention — not just the button — opens + the seeded Mini App rather than the bot profile. - **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`, default 25) to respect the Bot API flood limits.