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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user