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,6 +37,24 @@ type BotConfig struct {
// GameChannelID is the chat id of the bot's game channel for the admin channel
// post (TELEGRAM_GAME_CHANNEL_ID, optional; 0 disables channel posts).
GameChannelID int64
// ChatID is the chat id of the moderated discussion supergroup (a channel's linked
// chat) whose write access the bot gates by registration and moderation
// (TELEGRAM_CHAT_ID, optional; 0 disables chat gating). The bot must be an admin
// there with the "Ban users" right, and "chat_member" in its allowed updates.
ChatID int64
// PromoBotToken is the API token of the optional standalone promo bot run in this
// container — a second bot whose only job is to answer /start with a button that
// opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables
// the promo bot).
PromoBotToken string
// BotUsername is the main bot's @username without the leading @, used in the promo
// bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs).
BotUsername string
// BotLinkURL is the main bot's Mini App direct link — the same value the UI builds
// share links from (VITE_TELEGRAM_LINK), e.g. https://t.me/<bot>/<app>. The promo
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
BotLinkURL string
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
// the base of every launch button (TELEGRAM_MINIAPP_URL, required).
MiniAppURL string
@@ -114,6 +132,9 @@ func LoadBot() (BotConfig, error) {
TestEnv: os.Getenv("TELEGRAM_TEST_ENV") == "true",
OwnsUpdates: os.Getenv("TELEGRAM_OWNS_UPDATES") != "false",
SendRatePerSecond: defaultSendRatePerSecond,
PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"),
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
BotLink: BotLinkClientConfig{
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
@@ -128,6 +149,9 @@ func LoadBot() (BotConfig, error) {
if cfg.GameChannelID, err = envInt64("TELEGRAM_GAME_CHANNEL_ID", 0); err != nil {
return BotConfig{}, err
}
if cfg.ChatID, err = envInt64("TELEGRAM_CHAT_ID", 0); err != nil {
return BotConfig{}, err
}
if cfg.SendRatePerSecond, err = envInt("TELEGRAM_SEND_RATE_PER_SECOND", defaultSendRatePerSecond); err != nil {
return BotConfig{}, err
}
@@ -155,6 +179,9 @@ func LoadBot() (BotConfig, error) {
if cfg.BotLink.CertFile == "" || cfg.BotLink.KeyFile == "" || cfg.BotLink.CAFile == "" {
return BotConfig{}, fmt.Errorf("config: TELEGRAM_BOTLINK_TLS_CERT, _KEY and _CA are required")
}
if cfg.PromoBotToken != "" && (cfg.BotUsername == "" || cfg.BotLinkURL == "") {
return BotConfig{}, fmt.Errorf("config: TELEGRAM_BOT_USERNAME and TELEGRAM_BOT_LINK are required when TELEGRAM_PROMO_BOT_TOKEN is set")
}
return cfg, nil
}