feat(telegram): promo deep-link seeds English Scrabble for new users
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 50s

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.
This commit is contained in:
Ilia Denisov
2026-06-23 21:51:52 +02:00
parent c02262fcf7
commit 03dfc29a54
16 changed files with 260 additions and 24 deletions
+20 -10
View File
@@ -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=<payload> to it.
BotLinkURL string
// StartParam is the campaign payload appended as ?startapp=<StartParam> 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))
}