// Package promobot is the standalone promo bot: a second Telegram bot in the bot // container whose only job is to answer /start with a localized message and a button // that opens the MAIN bot's Mini App. It is self-contained — it never calls the // gateway or the game — so onboarding works even when the game is down. The button is // a URL to the main bot's direct Mini App link: a web_app button would launch the Mini // App under the promo bot's identity (its token would sign the initData), which the // main bot's validator would reject, so the cross-bot launch must be a t.me link. It // reuses the same link the UI builds invitation links from (VITE_TELEGRAM_LINK). package promobot import ( "cmp" "context" "html" "net/url" "strings" tgbot "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "go.uber.org/zap" "golang.org/x/time/rate" ) // Config configures the promo bot. type Config struct { // Token is the promo bot's Bot API token. Token string // APIBaseURL overrides the Bot API host ("" uses https://api.telegram.org). APIBaseURL string // TestEnv routes requests to the Bot API test environment. TestEnv bool // BotUsername is the main bot's @username without the leading @, named in the // message text. BotUsername string // 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 } // Bot is the promo bot wrapper around a Telegram Bot API client. type Bot struct { 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 // with the launch button. It does not start polling; call Run for that. func New(cfg Config, log *zap.Logger) (*Bot, error) { if log == nil { log = zap.NewNop() } 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) } opts := []tgbot.Option{ tgbot.WithDefaultHandler(t.handleStart), tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart), } if cfg.TestEnv { opts = append(opts, tgbot.UseTestEnvironment()) } if cfg.APIBaseURL != "" { opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL)) } api, err := tgbot.New(cfg.Token, opts...) if err != nil { return nil, err } t.api = api return t, nil } // Run sets the bot command, then blocks on the long-poll update loop until ctx is // cancelled. func (t *Bot) Run(ctx context.Context) { if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{ Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}}, }); err != nil { t.log.Warn("promo: set commands failed", zap.Error(err)) } t.api.Start(ctx) } // handleStart replies to any message (typically /start) with the localized promo text // 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 } // Only respond to a private /start: the promo bot is a one-on-one onboarding entry // point and should never reply to group messages. if update.Message.Chat.Type != models.ChatTypePrivate { return } if err := t.throttle(ctx); err != nil { return } lang := "" if update.Message.From != nil { lang = update.Message.From.LanguageCode } // 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, ParseMode: models.ParseModeHTML, ReplyMarkup: t.launchMarkup(button, param), }); err != nil { t.log.Warn("promo: reply to start failed", zap.Error(err)) } } // launchMarkup builds the single URL button that opens the main bot's Mini App at the // optional startapp payload. func (t *Bot) launchMarkup(buttonText, startParam string) *models.InlineKeyboardMarkup { return &models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{{ {Text: buttonText, URL: t.launchURL(startParam)}, }}, } } // launchURL appends the startapp payload to the main bot's Mini App link; an empty // payload returns the base link unchanged. func (t *Bot) launchURL(startParam string) string { if startParam == "" { return t.linkURL } u, err := url.Parse(t.linkURL) if err != nil { return t.linkURL } q := u.Query() q.Set("startapp", startParam) u.RawQuery = q.Encode() return u.String() } // throttle blocks until the rate limiter admits one send, or ctx is cancelled. It is // a no-op when no limiter is configured. func (t *Bot) throttle(ctx context.Context) error { if t.limiter == nil { return nil } return t.limiter.Wait(ctx) } // startPayload extracts the deep-link payload from a "/start " command; any // other text yields an empty payload (open the lobby). func startPayload(text string) string { const cmd = "/start" if !strings.HasPrefix(text, cmd) { return "" } return strings.TrimSpace(strings.TrimPrefix(text, cmd)) } // 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 "Откройте " + mention + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!" } return "Open " + mention + " and choose your game variant in the profile settings.", "🤩 I want to play!" }