// Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it // runs the long-poll update loop — replying to /start (with an optional deep-link // payload) and any other message with a Mini App launch button — and sends the // notification and admin messages the connector requests. The bot token lives only // in this process. package bot import ( "context" "net/http" "net/url" "strconv" "strings" "time" tgbot "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "go.uber.org/zap" "golang.org/x/time/rate" "scrabble/platform/telegram/internal/health" "scrabble/platform/telegram/internal/outbox" "scrabble/platform/telegram/internal/support" ) // botPollTimeout is the getUpdates long-poll timeout. It matches the go-telegram/bot default; we set // it explicitly only because wiring the health observer (WithHTTPClient) also sets the poll timeout. const botPollTimeout = time.Minute // Config configures the bot wrapper. type Config struct { // Token is the 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 // MiniAppURL is the base URL of the Mini App launch button. MiniAppURL string // SendRatePerSecond caps outbound sends (Notify and SendText) to respect the // Telegram Bot API flood limits; 0 disables the limiter. The burst equals the // per-second rate. SendRatePerSecond int // ChatID is the moderated discussion chat the bot gates write access in; 0 // disables chat gating (and the chat_member long-poll subscription). Gating needs // the bot to be an administrator there with the restrict-members right. Its public // @username is also resolved at startup for the /start welcome's discussion link. ChatID int64 // GameChannelID is the game channel whose public @username the /start welcome links // to (resolved from this id via getChat at startup); 0 omits that follow link. GameChannelID int64 // SupportChatID is the private forum supergroup the bot relays direct user messages // into (one topic per user) and reads operator replies from; 0 disables the support // relay. The bot must be an administrator there with the manage-topics and // delete-messages rights. SupportChatID int64 // SupportStore persists the support relay's state (topic mapping, block list, // relayed message ids); required when SupportChatID is set, ignored otherwise. SupportStore *support.Store // AcceptPayments enables the Telegram Stars rail: the bot then subscribes to // pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime // dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers. AcceptPayments bool // Health, when set, observes every Bot API request for health metrics (reported to the gateway // over the bot-link) and honours a 429's Retry-After. Nil disables the observation. Health *health.Reporter } // EligibilityResolver answers whether the Telegram user identified by externalID // (the decimal user id) may write in the moderated chat: registered and neither // admin-suspended nor chat-muted. The bot calls it when a user joins the chat. It is // late-bound (SetEligibilityResolver) because it is backed by the bot-link client, // which is built after the bot. type EligibilityResolver func(ctx context.Context, externalID string) (eligible bool, err error) // Bot wraps a Telegram Bot API client and the Mini App launch URL. type Bot struct { api *tgbot.Bot miniAppURL string log *zap.Logger // limiter throttles outbound sends to stay under the Bot API flood limits; nil // disables throttling. limiter *rate.Limiter // chatID is the moderated discussion chat (0 disables gating). chatID int64 // channelID is the game channel (0 omits its welcome follow link). channelID int64 // channelUsername and chatUsername are the public @usernames (without the leading // @) of the game channel and the discussion chat, resolved once at startup // (resolveWelcomeHandles) for the /start welcome's follow links; "" when unresolved. channelUsername string chatUsername string // botID is the bot's own Telegram user id (resolved at startup); it skips the // chat_member updates the bot's own restrict actions generate — the grant loop guard — // and the bot's own relayed copies in the support chat. botID int64 // eligibility resolves a joining user's chat write eligibility; nil leaves a // joiner muted (fail-closed) until it is wired. eligibility EligibilityResolver // supportChatID is the support relay's forum supergroup (0 disables the relay). supportChatID int64 // support persists the support relay's per-user state; nil when the relay is off. support *support.Store // supportLocks serialises per-user topic creation so a burst of a new user's // messages opens exactly one topic. supportLocks *keyedMutex // admins caches the support chat's administrator ids (who may reply and act). admins *adminCache // precheck validates a Stars pre_checkout order and forward delivers a completed payment; both // are late-bound (SetPaymentHandlers) over the bot-link, which is built after the bot. outbox // durably records completed payments before they are forwarded. All nil when the Stars rail is off. precheck PreCheckoutValidator forward PaymentForwarder outbox *outbox.Store } // New builds the bot wrapper, registering the /start handler and a default handler // that both reply with a Mini App 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{ miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID, channelID: cfg.GameChannelID, supportChatID: cfg.SupportChatID, support: cfg.SupportStore, } if cfg.SendRatePerSecond > 0 { t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond) } opts := []tgbot.Option{ tgbot.WithDefaultHandler(t.handleUpdate), tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart), } if t.supportEnabled() { t.supportLocks = newKeyedMutex() t.admins = &adminCache{} // The info-card buttons are callback_query updates; route them to the support // callback handler by their "sup:" data prefix. opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback)) } // Allowed updates default to "all except chat_member" (which already includes // pre_checkout_query and message-borne successful_payment). Specify an explicit set only when we // need chat_member (moderated chat) — and then re-add callback_query and, for the Stars rail, // pre_checkout_query, which the explicit set would otherwise drop. if cfg.ChatID != 0 { allowed := tgbot.AllowedUpdates{ models.AllowedUpdateMessage, models.AllowedUpdateMyChatMember, models.AllowedUpdateChatMember, } if t.supportEnabled() { allowed = append(allowed, models.AllowedUpdateCallbackQuery) } if cfg.AcceptPayments { allowed = append(allowed, models.AllowedUpdatePreCheckoutQuery) } opts = append(opts, tgbot.WithAllowedUpdates(allowed)) } if cfg.TestEnv { // Route to the Bot API test environment (.../bot/test/METHOD). opts = append(opts, tgbot.UseTestEnvironment()) } if cfg.APIBaseURL != "" { opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL)) } if cfg.Health != nil { // Observe every Bot API request centrally for health metrics and the 429 back-off. The // client timeout leaves slack over the long-poll hold (botPollTimeout - 1s server-side). base := &http.Client{Timeout: botPollTimeout + 10*time.Second} opts = append(opts, tgbot.WithHTTPClient(botPollTimeout, cfg.Health.Wrap(base))) } api, err := tgbot.New(cfg.Token, opts...) if err != nil { return nil, err } t.api = api return t, nil } // Run sets the bot commands and the Mini App menu button, 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("set commands failed", zap.Error(err)) } if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{ MenuButton: models.MenuButtonWebApp{ Type: models.MenuButtonTypeWebApp, Text: "Play", WebApp: models.WebAppInfo{URL: t.miniAppURL}, }, }); err != nil { t.log.Warn("set menu button failed", zap.Error(err)) } if t.chatID != 0 { t.logChatAdminStatus(ctx) } if t.supportEnabled() { t.initSupport(ctx) } t.resolveWelcomeHandles(ctx) t.api.Start(ctx) } // resolveWelcomeHandles resolves, once at startup, the public @usernames of the game // channel and the discussion chat from their configured ids (getChat), caching them for // the /start welcome's follow links. It runs before the update loop, so the handles are // set before any /start is handled; a chat that is unset, private (no public username) // or unreadable simply leaves its handle empty and the welcome omits that follow link. func (t *Bot) resolveWelcomeHandles(ctx context.Context) { t.channelUsername = t.resolveUsername(ctx, t.channelID, "game channel") t.chatUsername = t.resolveUsername(ctx, t.chatID, "discussion chat") } // resolveUsername returns the public @username (without the leading @) of the chat with // the given id, or "" when id is 0, the chat has no public username, or getChat fails — // logging the reason, since a missing handle silently drops a welcome follow link. func (t *Bot) resolveUsername(ctx context.Context, id int64, label string) string { if id == 0 { return "" } chat, err := t.api.GetChat(ctx, &tgbot.GetChatParams{ChatID: id}) if err != nil { t.log.Warn("welcome: getChat failed; follow link omitted", zap.String("chat", label), zap.Int64("id", id), zap.Error(err)) return "" } if chat.Username == "" { t.log.Warn("welcome: chat has no public @username; follow link omitted", zap.String("chat", label), zap.Int64("id", id)) return "" } t.log.Info("welcome: resolved follow link", zap.String("chat", label), zap.String("username", chat.Username)) return chat.Username } // logChatAdminStatus checks, at startup, whether the bot can actually gate the // moderated chat — it must be an administrator there with the restrict-members // ("Ban users") right, or Telegram delivers no chat_member updates and restricts // fail. It logs a prominent warning when the prerequisite is missing (the common // misconfiguration), so the cause is visible without reproducing a join. func (t *Bot) logChatAdminStatus(ctx context.Context) { me, err := t.api.GetMe(ctx) if err != nil { t.log.Warn("chat self-check: getMe failed", zap.Error(err)) return } t.botID = me.ID m, err := t.api.GetChatMember(ctx, &tgbot.GetChatMemberParams{ChatID: t.chatID, UserID: me.ID}) if err != nil { t.log.Warn("chat gating self-check failed: the bot cannot read the chat — is it added and is TELEGRAM_CHAT_ID the discussion group id?", zap.Int64("chat_id", t.chatID), zap.Error(err)) return } canRestrict := m.Type == models.ChatMemberTypeAdministrator && m.Administrator.CanRestrictMembers if !canRestrict { t.log.Warn(`chat gating WILL NOT WORK: the bot must be an administrator with the restrict-members ("Ban users") right`, zap.Int64("chat_id", t.chatID), zap.String("bot_status", string(m.Type))) return } t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID)) } // Notify sends a notification message with a Mini App launch button that opens the // app at startParam (empty opens the lobby). func (t *Bot) Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error { if err := t.throttle(ctx); err != nil { return err } _, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ ChatID: chatID, Text: text, ReplyMarkup: t.launchMarkup(buttonText, startParam), }) return err } // SendText sends a plain text message with no markup (admin use). func (t *Bot) SendText(ctx context.Context, chatID int64, text string) error { if err := t.throttle(ctx); err != nil { return err } _, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}) return err } // 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) } // handleStart replies to /start (with an optional deep-link payload) and to any // other message with a Mini App launch button. func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) { if update.Message == nil { return } // Reply only in a private chat: the Mini App launch button is an inline web_app // button, which Telegram permits only in private chats — replying to a group message // (the bot is an admin in the moderated chat and now receives its messages) fails with // BUTTON_TYPE_INVALID. In the group the bot only manages permissions, it never chats. if update.Message.Chat.Type != models.ChatTypePrivate { return } // The sender's Telegram language rides on the message itself (Message.from.language_code // in the Bot API — there is no separate user-update event); fall back to English when it // is absent. lang := "" if update.Message.From != nil { lang = update.Message.From.LanguageCode } text, button := startText(lang, t.channelUsername, t.chatUsername) startParam := startPayload(update.Message.Text) if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{ ChatID: update.Message.Chat.ID, Text: text, ReplyMarkup: t.launchMarkup(button, startParam), }); err != nil { t.log.Warn("reply to start failed", zap.Error(err)) } } // launchMarkup builds the single-button inline keyboard that opens the Mini App at // startParam. func (t *Bot) launchMarkup(buttonText, startParam string) *models.InlineKeyboardMarkup { return &models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{{ {Text: buttonText, WebApp: &models.WebAppInfo{URL: t.launchURL(startParam)}}, }}, } } // launchURL appends the deep-link start parameter to the Mini App URL as a startapp // query parameter; an empty parameter returns the base URL unchanged. func (t *Bot) launchURL(startParam string) string { if startParam == "" { return t.miniAppURL } u, err := url.Parse(t.miniAppURL) if err != nil { return t.miniAppURL } q := u.Query() q.Set("startapp", startParam) u.RawQuery = q.Encode() return u.String() } // 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)) } // handleUpdate is the default-handler dispatcher: a chat-member change in the // moderated chat drives the write-access gate; anything else is treated as a message // and gets the Mini App launch reply. func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.Update) { if update.ChatMember != nil { t.handleChatMember(ctx, update.ChatMember) return } // Telegram Stars: the pre_checkout gate (validated against the backend) and the completed // payment (persisted to the outbox and forwarded) — before the support relay, so a payment // message is never mistaken for a support DM or given a launch reply. if update.PreCheckoutQuery != nil { t.handlePreCheckout(ctx, update.PreCheckoutQuery) return } if update.Message != nil && update.Message.SuccessfulPayment != nil { t.handleSuccessfulPayment(ctx, update.Message) return } // Support relay (when enabled): a non-/start message — /start has its own handler — // is either an operator's reply in the support chat or a user's direct message to // relay. Everything else falls through to the Mini App launch reply. if t.supportEnabled() && update.Message != nil { switch { case update.Message.Chat.ID == t.supportChatID: t.handleSupportGroupMessage(ctx, update.Message) return case update.Message.Chat.Type == models.ChatTypePrivate: t.handleSupportUserMessage(ctx, update.Message) return } } t.handleStart(ctx, api, update) } // SetEligibilityResolver wires the chat-eligibility resolver after construction (the // bot-link client backing it is built after the bot). func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) { t.eligibility = resolve } // handleChatMember keeps a chat member's write access in sync with their eligibility. // The chat allows sending by default, so the bot mutes an ineligible member (not // registered, or admin-suspended, or chat_muted) and restores an eligible one it had // muted; an eligible member that can already send is left untouched. It acts only when // the current state differs from the desired one, so it is idempotent and does not // re-act on its own change; a resolve failure makes no change. func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated) { user := chatMemberUser(cm.NewChatMember) var uid int64 if user != nil { uid = user.ID } // Log every chat_member update the bot receives: the one place to see whether // Telegram delivers joins, for which chat, the transition, who performed it, and the // new member's send/membership state. canSend, isMember := restrictedSendState(cm.NewChatMember) t.log.Debug("chat_member update", zap.Int64("chat_id", cm.Chat.ID), zap.Int64("configured_chat_id", t.chatID), zap.Int64("user_id", uid), zap.Int64("actor_id", cm.From.ID), zap.String("old_status", string(cm.OldChatMember.Type)), zap.String("new_status", string(cm.NewChatMember.Type)), zap.Bool("new_can_send", canSend), zap.Bool("new_is_member", isMember)) if t.chatID == 0 || cm.Chat.ID != t.chatID { return } if user == nil || user.IsBot { return } // Loop guard: the bot's own restrict re-fires a chat_member update whose performer is // the bot; skip those so a grant never re-triggers itself. if t.botID != 0 && cm.From.ID == t.botID { return } // The chat allows sending by default and the bot only restricts: Telegram intersects // the chat default with the per-user permission, so a per-user grant cannot exceed a // deny-by-default — the gate must mute the ineligible, not grant the eligible. // Determine whether the user is in the chat and can currently send: a plain member // follows the permissive default; a restricted member can send only with // CanSendMessages, and only while a member. var inChat, currentlyCanSend bool switch cm.NewChatMember.Type { case models.ChatMemberTypeMember: inChat, currentlyCanSend = true, true case models.ChatMemberTypeRestricted: inChat, currentlyCanSend = isMember, canSend default: return // left / kicked / administrator / owner — not a member to gate } if !inChat { return } if t.eligibility == nil { t.log.Warn("chat access: eligibility resolver not wired", zap.Int64("user_id", uid)) return } eligible, err := t.eligibility(ctx, strconv.FormatInt(user.ID, 10)) if err != nil { t.log.Warn("chat access eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err)) return } t.log.Debug("chat access evaluated", zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible), zap.Bool("can_send", currentlyCanSend)) // Desired: an eligible user may send, an ineligible one may not. Act only when the // current state differs — idempotent, a no-op for the common eligible member, and it // keeps the bot from re-acting on its own change. if eligible == currentlyCanSend { return } if err := t.setChatWrite(ctx, user.ID, eligible); err != nil { t.log.Warn("set chat write failed", zap.Int64("user_id", user.ID), zap.Bool("can_send", eligible), zap.Error(err)) return } t.log.Info("chat access applied", zap.Int64("user_id", user.ID), zap.Bool("can_send", eligible)) } // ApplyChatGate applies a chat-gate command (an admin block/unblock or chat_muted // change relayed by the gateway): it sets the user's write access, but only when they // are currently in the chat. Bots cannot list members, so it probes the single user // with getChatMember and is a no-op when they are absent (left/kicked) or an // administrator (who cannot be restricted). It reports whether a restriction was // applied. func (t *Bot) ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error) { if t.chatID == 0 { return false, nil } member, err := t.api.GetChatMember(ctx, &tgbot.GetChatMemberParams{ChatID: t.chatID, UserID: userID}) if err != nil { return false, err } switch member.Type { case models.ChatMemberTypeMember, models.ChatMemberTypeRestricted: if err := t.setChatWrite(ctx, userID, allow); err != nil { return false, err } t.log.Info("chat gate applied", zap.Int64("user_id", userID), zap.Bool("allow", allow)) return true, nil default: t.log.Debug("chat gate: user not in chat, skipped", zap.Int64("user_id", userID), zap.String("status", string(member.Type))) return false, nil // absent, or an admin/owner who cannot be restricted } } // setChatWrite restricts the user in the moderated chat to either the full send // permission set (allow) or none (mute); the non-send permissions stay at their // default-deny either way. func (t *Bot) setChatWrite(ctx context.Context, userID int64, allow bool) error { perms := models.ChatPermissions{} if allow { perms = chatWritePerms() } _, err := t.api.RestrictChatMember(ctx, &tgbot.RestrictChatMemberParams{ ChatID: t.chatID, UserID: userID, Permissions: &perms, }) return err } // chatWritePerms grants a member the ability to send every kind of message; the // non-send permissions stay denied. func chatWritePerms() models.ChatPermissions { return models.ChatPermissions{ CanSendMessages: true, CanSendAudios: true, CanSendDocuments: true, CanSendPhotos: true, CanSendVideos: true, CanSendVideoNotes: true, CanSendVoiceNotes: true, CanSendPolls: true, CanSendOtherMessages: true, CanAddWebPagePreviews: true, } } // restrictedSendState returns a restricted member's text-send permission and whether // they are currently a member of the chat; (false, false) for any non-restricted // status (the fields exist only on the restricted variant). func restrictedSendState(m models.ChatMember) (canSend, isMember bool) { if m.Type == models.ChatMemberTypeRestricted && m.Restricted != nil { return m.Restricted.CanSendMessages, m.Restricted.IsMember } return false, false } // chatMemberUser returns the user a ChatMember refers to across the union variants, // or nil for an unrecognised type. func chatMemberUser(m models.ChatMember) *models.User { switch m.Type { case models.ChatMemberTypeOwner: return m.Owner.User case models.ChatMemberTypeAdministrator: return &m.Administrator.User case models.ChatMemberTypeMember: return m.Member.User case models.ChatMemberTypeRestricted: return m.Restricted.User case models.ChatMemberTypeLeft: return m.Left.User case models.ChatMemberTypeBanned: return m.Banned.User } return nil }