internal/domain/bot.go
Rama main · réplica de lectura del repositorio original.
package domain
import (
"errors"
"strconv"
"strings"
"time"
"unicode/utf8"
)
const (
// MaxBotsPerOwner is the per-user bot creation limit (aligned with the official appConfig bots_create_limit_default).
MaxBotsPerOwner = 20
// BotTokenSecretLength is the random-part length of a bot token (official format <bot_id>:<35 digits>).
BotTokenSecretLength = 35
// BotUsernameSuffix is the mandatory suffix of a bot username (case-insensitive).
BotUsernameSuffix = "bot"
// MaxBotNameLength is the bot display name (first_name) limit.
MaxBotNameLength = 64
// MaxBotInlinePlaceholderLen is the inline mode placeholder limit (bots.inline_placeholder).
MaxBotInlinePlaceholderLen = 128
// MaxBotAppShortNameLen is the bot app short_name limit.
MaxBotAppShortNameLen = 64
// MaxBotAppTitleLen is the bot app title limit.
MaxBotAppTitleLen = 128
// MaxBotAppDescriptionLen is the bot app description limit.
MaxBotAppDescriptionLen = 512
// MaxBotAppURLLen is the bot app / attach menu webview URL limit.
MaxBotAppURLLen = 512
// MaxBotAttachMenuPeerTypes limits the attach menu peer_types count to avoid an unbounded response.
MaxBotAttachMenuPeerTypes = 8
// MaxBotAttachMenuIcons limits the attach menu icon variant count.
MaxBotAttachMenuIcons = 8
// MaxBotPreviewMedia is the per-bot main mini app preview media limit.
MaxBotPreviewMedia = 20
// MaxBotRequestedPeerQuantity limits how many peers a single request-peer selection may include.
MaxBotRequestedPeerQuantity = 10
// MaxBotCustomMethodLen limits the WebView custom method name length.
MaxBotCustomMethodLen = 128
// MaxBotCustomMethodPayloadLen limits the WebView custom method JSON payload length.
MaxBotCustomMethodPayloadLen = 4096
)
// Bot business errors.
var (
ErrBotTokenInvalid = errors.New("bot token invalid")
ErrBotsTooMany = errors.New("bot create limit exceeded")
ErrBotNameInvalid = errors.New("bot name invalid")
ErrBotUsernameInvalid = errors.New("bot username invalid")
ErrBotNotFound = errors.New("bot not found")
// ErrBotCommandInvalid means the command name/description is invalid (bots.setBotCommands).
ErrBotCommandInvalid = errors.New("bot command invalid")
// ErrBotInfoInvalid means setBotInfo's name/about/description is out of bounds.
ErrBotInfoInvalid = errors.New("bot info invalid")
// ErrBotMenuButtonInvalid means the menu button text/URL is invalid.
ErrBotMenuButtonInvalid = errors.New("bot menu button invalid")
// ErrBotInlinePlaceholderInvalid means the inline placeholder text is out of bounds for anything other than empty.
ErrBotInlinePlaceholderInvalid = errors.New("bot inline placeholder invalid")
// ErrBotAppInvalid means the bot app metadata, URL or access hash is invalid.
ErrBotAppInvalid = errors.New("bot app invalid")
// ErrBotAppShortNameInvalid means the bot app short_name is invalid.
ErrBotAppShortNameInvalid = errors.New("bot app short name invalid")
// ErrBotAttachMenuInvalid means the attach menu catalog or user state is invalid.
ErrBotAttachMenuInvalid = errors.New("bot attach menu invalid")
// ErrBotRequestedButtonInvalid means the request-peer button context is invalid or expired.
ErrBotRequestedButtonInvalid = errors.New("bot requested button invalid")
// ErrBotDownloadParamsInvalid means the mini app download parameters failed the security policy.
ErrBotDownloadParamsInvalid = errors.New("bot download params invalid")
// ErrBotCustomMethodUnavailable means there is no bot-side answer that can complete the WebView custom method.
ErrBotCustomMethodUnavailable = errors.New("bot custom method unavailable")
// ErrBotSessionsNotRevoked means the token was rotated but revoking the logged-in sessions failed
// (the token cannot be rolled back, so the caller must tell the user to retry to ensure the old sessions are terminated).
ErrBotSessionsNotRevoked = errors.New("bot sessions not revoked")
// ErrBotDeletionUnsupported means the current store does not implement account deletion (only Postgres does),
// so the caller must honestly tell the user this server does not support it, rather than claim it was deleted.
ErrBotDeletionUnsupported = errors.New("bot deletion unsupported")
)
const (
// MaxBotCommands is the per-bot command limit (default scope).
MaxBotCommands = 100
// MaxBotCommandLen is the command name length limit (excluding the leading /).
MaxBotCommandLen = 32
// MaxBotCommandDescriptionLen is the command description length limit.
MaxBotCommandDescriptionLen = 256
// MaxBotAboutLen is the bot about (users.about) length limit.
MaxBotAboutLen = 120
// MaxBotDescriptionLen is the bot description ("What can this bot do?") length limit.
MaxBotDescriptionLen = 512
// MaxBotMenuButtonTextLen / MaxBotMenuButtonURLLen are the menu button text/URL limits.
MaxBotMenuButtonTextLen = 64
MaxBotMenuButtonURLLen = 512
)
// BotCommand is one command of a bot (a botInfo.commands element).
type BotCommand struct {
Command string `json:"command"`
Description string `json:"description"`
Ephemeral bool `json:"ephemeral,omitempty"`
}
// BotMenuButtonType identifies the menu button type.
type BotMenuButtonType int
const (
// BotMenuButtonDefault is the default (no special button shown; the client decides the '/' round button by whether commands is non-empty).
BotMenuButtonDefault BotMenuButtonType = 0
// BotMenuButtonCommands explicitly makes the client show the command menu button.
BotMenuButtonCommands BotMenuButtonType = 1
// BotMenuButtonWebView is a web view button with text + URL.
BotMenuButtonWebView BotMenuButtonType = 2
)
// BotMenuButton is a bot's menu button (global per bot; the per-user dimension is P2 and not done).
type BotMenuButton struct {
Type BotMenuButtonType
Text string
URL string
}
// BotInfoUpdate is a partial update of bots.setBotInfo (name→users.first_name,
// about→users.about, description→bots.description). When a SetXxx is false the field is left untouched.
type BotInfoUpdate struct {
SetName bool
Name string
SetAbout bool
About string
SetDescription bool
Description string
}
// BotApp is one item of the mini app catalog. A bot can own multiple apps; ID/access_hash
// are stable after creation, and short_name is unique within the same bot.
type BotApp struct {
ID int64
BotUserID int64
ShortName string
Title string
Description string
URL string
PhotoID int64
DocumentID int64
AccessHash int64
Hash int64
Inactive bool
RequestWriteAccess bool
HasSettings bool
Main bool
}
// BotAppSettings is the protocol-neutral representation of BotInfo.app_settings.
type BotAppSettings struct {
PlaceholderPath []byte
BackgroundColor int
BackgroundDarkColor int
HeaderColor int
HeaderDarkColor int
HasBackgroundColor bool
HasBackgroundDark bool
HasHeaderColor bool
HasHeaderDarkColor bool
}
// BotAppPreviewMedia is the persisted snapshot of main mini app preview media.
type BotAppPreviewMedia struct {
ID int64
BotUserID int64
AppID int64
Position int
PhotoID int64
DocumentID int64
}
// BotAttachMenuIconColor describes a theme-color override for an attach menu icon.
type BotAttachMenuIconColor struct {
Name string
Color int
}
// BotAttachMenuIcon describes a platform icon document reference for the attach menu.
type BotAttachMenuIcon struct {
Name string
DocumentID int64
Colors []BotAttachMenuIconColor
}
// BotAttachMenuBot is one item of the global attach/side menu catalog.
type BotAttachMenuBot struct {
BotUserID int64
AppID int64
ShortName string
Inactive bool
HasSettings bool
RequestWriteAccess bool
ShowInAttachMenu bool
ShowInSideMenu bool
SideMenuDisclaimerNeeded bool
PeerTypes []string
Icons []BotAttachMenuIcon
}
// BotAttachMenuState is a user's enable and write-permission state for an attach menu bot.
type BotAttachMenuState struct {
UserID int64
BotUserID int64
Enabled bool
WriteAllowed bool
}
// BotRequestedWebViewButton is the request-peer context created by bots.requestWebViewButton.
type BotRequestedWebViewButton struct {
WebAppReqID string
BotUserID int64
UserID int64
ButtonID int
Text string
PeerType string
MaxQuantity int
PeerFilter *BotRequestPeerFilter
NameRequested bool
UsernameRequested bool
PhotoRequested bool
CreatedAt time.Time
ExpiresAt time.Time
}
// BotWebViewCustomMethodQuery is the pending record of a custom method. When there is no bot-side answer
// registry it only records the query and explicitly returns a blocked error, avoiding a false success.
type BotWebViewCustomMethodQuery struct {
ID string
BotUserID int64
UserID int64
CustomMethod string
ParamsJSON string
CreatedAt time.Time
ExpiresAt time.Time
}
// ValidBotCommandName validates a command name: 1-32 lowercase letters/digits/underscores (excluding the leading /).
func ValidBotCommandName(cmd string) bool {
if len(cmd) == 0 || len(cmd) > MaxBotCommandLen {
return false
}
for _, r := range cmd {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
default:
return false
}
}
return true
}
// BotProfile is one bots table row: the bot account's metadata and token.
// The display name/username/about reuse the users row and are not duplicated here.
// BotInvoice is one XTR invoice a bot sent as a chat message.
//
// payments.sendPaymentForm carries no purpose in this layer, so the price is
// taken from this record: the client only sends the message id and the server
// resolves the amount it itself advertised. That is what makes a bot invoice
// settleable without any extra TL constructor.
//
// ChargeID is the telegram_payment_charge_id the client reports, kept so
// refundStarPayment can find the invoice again.
type BotInvoice struct {
ID int64
BotUserID int64
ChatID int64
MessageID int
Title string
Description string
// Amount is in whole Stars. currency is always XTR: telesrv does not model
// a fiat checkout, so there is nothing else an invoice could settle.
Amount int64
Currency string
// Payload is the Bot API start_param, opaque to the server and echoed back
// to the bot so it can tell what was bought.
Payload string
ChargeID string
PayerID int64
Paid bool
PaidAt int
Date int
Refunded bool
}
// Bot invoice failures.
var (
ErrBotInvoiceInvalid = errors.New("bot invoice invalid")
ErrBotInvoiceNotFound = errors.New("bot invoice not found")
// ErrBotInvoiceSettled means the invoice is already paid, so paying it
// again would charge the buyer twice.
ErrBotInvoiceSettled = errors.New("bot invoice already settled")
// ErrBotInvoiceRefunded means the charge was already reversed.
ErrBotInvoiceRefunded = errors.New("bot invoice already refunded")
)
// Invoice is the messageMediaInvoice payload a bot advertises.
//
// It is deliberately separate from PremiumInvoice: that type carries a Premium
// plan (months, plan version, entitlement message) and its Valid() rejects a
// plain product, so reusing it for a bot sale would mean filling in fields that
// mean nothing here. The Premium bot keeps its own type.
type Invoice struct {
Title string `json:"title"`
Description string `json:"description"`
AmountStars int64 `json:"amount_stars"`
StartParam string `json:"start_param,omitempty"`
}
// Valid reports whether the invoice can be projected onto messageMediaInvoice.
func (i Invoice) Valid() bool {
return utf8.RuneCountInString(i.Title) > 0 &&
utf8.RuneCountInString(i.Title) <= BotInvoiceMaxTitle &&
utf8.RuneCountInString(i.Description) > 0 &&
utf8.RuneCountInString(i.Description) <= BotInvoiceMaxDescription &&
i.AmountStars > 0 && len(i.StartParam) <= BotInvoiceMaxPayload
}
// BotInvoiceMaxTitle is the messageMediaInvoice title budget.
const BotInvoiceMaxTitle = 32
// BotInvoiceMaxDescription is the messageMediaInvoice description budget.
const BotInvoiceMaxDescription = 255
// BotInvoiceMaxPayload is the start_param budget Telegram allows.
const BotInvoiceMaxPayload = 64
// GramsrvBotUserID is the built-in @gramsrv moderation bot account used by
// WithGramsrvAdmin. Zero means the bot is not provisioned on this server.
const GramsrvBotUserID = int64(0)
// Valid reports whether the invoice describes a well-formed XTR sale.
func (i BotInvoice) Valid() bool {
return i.BotUserID > 0 && i.ChatID != 0 && i.MessageID > 0 && i.Amount > 0 &&
i.Currency == PremiumCurrencyStars &&
utf8.RuneCountInString(i.Title) <= BotInvoiceMaxTitle &&
utf8.RuneCountInString(i.Description) <= BotInvoiceMaxDescription &&
len(i.Payload) <= BotInvoiceMaxPayload
}
// Settled reports whether the invoice has already been paid, which makes a
// replayed sendPaymentForm a duplicate rather than a second charge.
func (i BotInvoice) Settled() bool { return i.Paid && i.ChargeID != "" }
type BotProfile struct {
BotUserID int64
OwnerUserID int64
TokenSecret string
Description string
Commands []BotCommand
ChatHistory bool // privacy mode off = true (bot_chat_history flag)
Nochats bool // true = groups not allowed (bot_nochats flag)
InlineGeo bool
InlinePlaceholder string
MenuButton BotMenuButton
HasMainApp bool
HasAttachMenu bool
HasPreviewMedias bool
AppSettings *BotAppSettings
}
// BotChatState is the persisted state machine of a built-in bot's (currently only BotFather's) chat with a user.
type BotChatState struct {
BotUserID int64
UserID int64
// Command is the in-progress main command (newbot/token/revoke).
Command string `json:"command"`
// Step is the current step within the main command (name/username/choose).
Step string `json:"step"`
// Draft buffers intermediate input across steps (e.g. the name already collected by newbot).
Draft map[string]string `json:"draft,omitempty"`
}
// FormatBotToken assembles the full bot token (<bot_user_id>:<secret>).
func FormatBotToken(botUserID int64, secret string) string {
return strconv.FormatInt(botUserID, 10) + ":" + secret
}
// ParseBotToken parses the full bot token. ok=false on an illegal format (without distinguishing the cause;
// the caller uniformly returns ACCESS_TOKEN_INVALID to avoid leaking existence).
func ParseBotToken(token string) (botUserID int64, secret string, ok bool) {
idPart, secret, found := strings.Cut(token, ":")
if !found || idPart == "" || secret == "" {
return 0, "", false
}
id, err := strconv.ParseInt(idPart, 10, 64)
if err != nil || id <= 0 {
return 0, "", false
}
return id, secret, true
}
// ValidBotUsername validates a bot username: 5-32 chars, starting with a letter, [A-Za-z0-9_],
// ending in bot (case-insensitive). Seed accounts such as BotFather do not go through this validation.
func ValidBotUsername(username string) bool {
if len(username) < 5 || len(username) > 32 {
return false
}
for i, r := range username {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9', r == '_':
if i == 0 {
return false
}
default:
return false
}
}
return strings.HasSuffix(strings.ToLower(username), BotUsernameSuffix)
}