feat(telegram): promo bot + channel-chat moderation gate
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
Add a second standalone promo bot to the bot container (answers /start with a localized message + a URL button into the main bot's Mini App) and gate write access in a channel's linked discussion chat: grant on join when the Telegram user is registered and neither admin-suspended nor holding a new chat_muted role, and revoke/grant on the matching moderation change for a member currently in the chat. Eligibility (registered AND NOT suspended AND NOT chat_muted; the game suspension dominates) is resolved once in the backend and reached two ways: the bot's join-time unary ResolveChatEligibility over the existing mTLS bot-link, and a backend chat_access_changed event -> gateway -> ChatGate command (idempotent; a temporary-block-expiry sweeper may over-emit). The bot guards the block/unblock path with getChatMember, since bots cannot list members. A web_app button cannot open another bot's Mini App (it signs initData with the sending bot's token), so the promo button is a t.me ?startapp URL reusing the UI's VITE_TELEGRAM_LINK. The bot must be a chat admin with the restrict-members right and chat_member in its allowed updates. No schema change: chat_muted reuses the data-driven account_roles table.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"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=<payload> to it.
|
||||
BotLinkURL 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
|
||||
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, 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, forwarding any /start payload.
|
||||
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
|
||||
if update.Message == nil {
|
||||
return
|
||||
}
|
||||
if err := t.throttle(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
lang := ""
|
||||
if update.Message.From != nil {
|
||||
lang = update.Message.From.LanguageCode
|
||||
}
|
||||
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)),
|
||||
}); 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 <payload>" 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, naming the main bot
|
||||
// (Russian for a "ru" language code, English otherwise).
|
||||
func promoText(lang, username string) (text, button string) {
|
||||
if strings.HasPrefix(strings.ToLower(lang), "ru") {
|
||||
return "Откройте @" + username + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
|
||||
}
|
||||
return "Open @" + username + " and choose your game variant in the profile settings.", "🤩 I want to play!"
|
||||
}
|
||||
Reference in New Issue
Block a user