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)) }