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

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:
Ilia Denisov
2026-06-21 14:46:51 +02:00
parent 41d21f3f6f
commit e71e40eef5
42 changed files with 2082 additions and 68 deletions
+37 -2
View File
@@ -22,6 +22,7 @@ import (
"scrabble/platform/telegram/internal/bot"
"scrabble/platform/telegram/internal/botlink"
"scrabble/platform/telegram/internal/config"
"scrabble/platform/telegram/internal/promobot"
)
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
@@ -70,6 +71,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
TestEnv: cfg.TestEnv,
MiniAppURL: cfg.MiniAppURL,
SendRatePerSecond: cfg.SendRatePerSecond,
ChatID: cfg.ChatID,
}, logger)
if err != nil {
return err
@@ -80,19 +82,47 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
return err
}
exec := botlink.NewExecutor(b, cfg.GameChannelID, logger)
client := botlink.NewClient(botlink.ClientConfig{
client, err := botlink.NewClient(botlink.ClientConfig{
GatewayAddr: cfg.BotLink.GatewayAddr,
InstanceID: cfg.BotLink.InstanceID,
OwnsUpdates: cfg.OwnsUpdates,
Creds: credentials.NewTLS(tlsCfg),
ReconnectDelay: cfg.BotLink.ReconnectDelay,
}, exec, logger)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
// The chat-join eligibility query rides the same bot-link connection; wire it into
// the bot after the client is built — the late binding that breaks the bot <->
// client construction cycle.
b.SetEligibilityResolver(client.ResolveChatEligibility)
// The optional standalone promo bot: a second bot (its own token) that only answers
// /start with a button opening the main bot's Mini App. It is self-contained — no
// bot-link, no gateway — so onboarding works even when the game is down.
var promo *promobot.Bot
if cfg.PromoBotToken != "" {
promo, err = promobot.New(promobot.Config{
Token: cfg.PromoBotToken,
APIBaseURL: cfg.APIBaseURL,
TestEnv: cfg.TestEnv,
BotUsername: cfg.BotUsername,
BotLinkURL: cfg.BotLinkURL,
SendRatePerSecond: cfg.SendRatePerSecond,
}, logger)
if err != nil {
return err
}
}
logger.Info("telegram bot starting",
zap.String("gateway", cfg.BotLink.GatewayAddr),
zap.String("miniapp_url", cfg.MiniAppURL),
zap.Bool("owns_updates", cfg.OwnsUpdates),
zap.Bool("test_env", cfg.TestEnv))
zap.Bool("test_env", cfg.TestEnv),
zap.Bool("chat_gating", cfg.ChatID != 0),
zap.Bool("promo_bot", promo != nil))
var wg sync.WaitGroup
// The long-poll holds the exclusive getUpdates lease (one bot per token); a bot
@@ -105,6 +135,11 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
logger.Error("bot-link client stopped", zap.Error(err))
}
})
// The promo bot runs its own getUpdates long-poll on its own token (no 409 with
// the main bot's lease).
if promo != nil {
wg.Go(func() { promo.Run(ctx) })
}
<-ctx.Done()
wg.Wait()