c494da553a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
The main bot is now an admin in the moderated discussion group and receives its messages (allowed_updates includes message). Its default handler replied to every message with a Mini App launch button — an inline web_app button, which Telegram permits only in private chats — so replying in the group failed with BUTTON_TYPE_INVALID (silently: the send fails, no user-facing error). Reply only in a private chat; in the group the bot only manages permissions. The promo bot gets the same guard.
433 lines
16 KiB
Go
433 lines
16 KiB
Go
// 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/url"
|
|
"strconv"
|
|
"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 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.
|
|
ChatID int64
|
|
}
|
|
|
|
// 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
|
|
// 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.
|
|
botID int64
|
|
// eligibility resolves a joining user's chat write eligibility; nil leaves a
|
|
// joiner muted (fail-closed) until it is wired.
|
|
eligibility EligibilityResolver
|
|
}
|
|
|
|
// 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}
|
|
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 cfg.ChatID != 0 {
|
|
// chat_member updates are off by default; subscribe explicitly (alongside
|
|
// messages) so the bot sees joins in the moderated chat. The bot must also be an
|
|
// administrator there for Telegram to deliver them.
|
|
opts = append(opts, tgbot.WithAllowedUpdates(tgbot.AllowedUpdates{
|
|
models.AllowedUpdateMessage,
|
|
models.AllowedUpdateMyChatMember,
|
|
models.AllowedUpdateChatMember,
|
|
}))
|
|
}
|
|
if cfg.TestEnv {
|
|
// Route to the Bot API test environment (.../bot<token>/test/METHOD).
|
|
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 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)
|
|
}
|
|
t.api.Start(ctx)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
startParam := startPayload(update.Message.Text)
|
|
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
|
|
ChatID: update.Message.Chat.ID,
|
|
Text: "Tap to open Scrabble.",
|
|
ReplyMarkup: t.launchMarkup("Open Scrabble", 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 <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))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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
|
|
}
|