Ctrl K
ComunidadGuías…

internal/domain/message_markup.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package domain

import (
	"errors"
	"net/url"
	"strings"
	"unicode/utf8"
)

const (
	// MaxMarkupRows limits the inline keyboard row count.
	MaxMarkupRows = 100
	// MaxMarkupButtonsPerRow limits the per-row button count.
	MaxMarkupButtonsPerRow = 8
	// MaxMarkupButtonsTotal limits the inline keyboard total button count (aligned with the official ~100).
	MaxMarkupButtonsTotal = 100
	// MaxCallbackDataLen is the byte limit of a callback button's data (aligned with Bot API 1-64).
	MaxCallbackDataLen = 64
	// MaxMarkupButtonTextLen is the button text length limit (rune count).
	MaxMarkupButtonTextLen = 256
	// MaxReplyKeyboardButtonTextLen aligns with Bot API KeyboardButton's 1-64 character constraint.
	MaxReplyKeyboardButtonTextLen = 64
	// MaxReplyKeyboardPlaceholderLen is the placeholder limit for the reply keyboard / force reply input field.
	MaxReplyKeyboardPlaceholderLen = 64
	// MaxBotCallbackAnswerLen is the text limit of a callback answer alert/toast.
	MaxBotCallbackAnswerLen = 200
	// MaxStartParamLen is the deep-link payload limit of messages.startBot (aligned with the official 64).
	MaxStartParamLen = 64
)

// Markup / callback business errors.
var (
	// ErrButtonDataInvalid means the callback data is out of bounds (>64 bytes).
	ErrButtonDataInvalid = errors.New("button data invalid")
	// ErrButtonInvalid means the keyboard structure is invalid (row/button count exceeded, empty/too-long text).
	ErrButtonInvalid = errors.New("button invalid")
	// ErrButtonURLInvalid means a url button's link is invalid (not https).
	ErrButtonURLInvalid = errors.New("button url invalid")
	// ErrButtonTypeInvalid means the button type is unsupported in P3 (webview/game/url_auth/request_* etc.).
	ErrButtonTypeInvalid = errors.New("button type invalid")
	// ErrStartParamInvalid means startBot's start_param is out of bounds.
	ErrStartParamInvalid = errors.New("start param invalid")
)

// MarkupButtonType identifies a message keyboard button type.
type MarkupButtonType string

const (
	// MarkupButtonText is a plain text button of the reply keyboard; tapping it makes the client send a standard text message.
	MarkupButtonText MarkupButtonType = "text"
	// MarkupButtonCallback is keyboardButtonCallback (tapping it triggers getBotCallbackAnswer).
	MarkupButtonCallback MarkupButtonType = "callback"
	// MarkupButtonURL is keyboardButtonUrl (tapping it opens a link).
	MarkupButtonURL MarkupButtonType = "url"
	// MarkupButtonLoginURL is Bot API login_url / inputKeyboardButtonUrlAuth.
	// The target bot is resolved and the linked origin is verified before the
	// message is persisted; ButtonID is the stable flattened keyboard index
	// returned to clients as keyboardButtonUrlAuth.button_id.
	MarkupButtonLoginURL        MarkupButtonType = "login_url"
	MarkupButtonRequestPhone    MarkupButtonType = "request_phone"
	MarkupButtonRequestLocation MarkupButtonType = "request_location"
	MarkupButtonRequestPoll     MarkupButtonType = "request_poll"
	MarkupButtonRequestPeer     MarkupButtonType = "request_peer"
	MarkupButtonWebView         MarkupButtonType = "webview"
	MarkupButtonSimpleWebView   MarkupButtonType = "simple_webview"
	MarkupButtonSwitchInline    MarkupButtonType = "switch_inline"
	MarkupButtonCopy            MarkupButtonType = "copy"
	// MarkupButtonBuy is keyboardButtonBuy. It is valid only on an inline
	// keyboard attached to invoice media.
	MarkupButtonBuy MarkupButtonType = "buy"
)

// MarkupButtonStyle is the protocol-neutral semantic button color. Telegram
// intentionally exposes semantic colors instead of arbitrary RGB values.
type MarkupButtonStyle string

const (
	MarkupButtonStylePrimary MarkupButtonStyle = "primary"
	MarkupButtonStyleDanger  MarkupButtonStyle = "danger"
	MarkupButtonStyleSuccess MarkupButtonStyle = "success"
)

// BotRequestAdminRights mirrors Bot API ChatAdministratorRights without
// importing protocol types into persisted message state.
type BotRequestAdminRights struct {
	Anonymous            bool `json:"anonymous,omitempty"`
	ManageChat           bool `json:"manage_chat,omitempty"`
	DeleteMessages       bool `json:"delete_messages,omitempty"`
	ManageVideoChats     bool `json:"manage_video_chats,omitempty"`
	RestrictMembers      bool `json:"restrict_members,omitempty"`
	PromoteMembers       bool `json:"promote_members,omitempty"`
	ChangeInfo           bool `json:"change_info,omitempty"`
	InviteUsers          bool `json:"invite_users,omitempty"`
	PostStories          bool `json:"post_stories,omitempty"`
	EditStories          bool `json:"edit_stories,omitempty"`
	DeleteStories        bool `json:"delete_stories,omitempty"`
	PostMessages         bool `json:"post_messages,omitempty"`
	EditMessages         bool `json:"edit_messages,omitempty"`
	PinMessages          bool `json:"pin_messages,omitempty"`
	ManageTopics         bool `json:"manage_topics,omitempty"`
	ManageDirectMessages bool `json:"manage_direct_messages,omitempty"`
}

type BotRequestPeerFilter struct {
	UserIsBotSet       bool                   `json:"user_is_bot_set,omitempty"`
	UserIsBot          bool                   `json:"user_is_bot,omitempty"`
	UserIsPremiumSet   bool                   `json:"user_is_premium_set,omitempty"`
	UserIsPremium      bool                   `json:"user_is_premium,omitempty"`
	ChatHasUsernameSet bool                   `json:"chat_has_username_set,omitempty"`
	ChatHasUsername    bool                   `json:"chat_has_username,omitempty"`
	ChatIsForumSet     bool                   `json:"chat_is_forum_set,omitempty"`
	ChatIsForum        bool                   `json:"chat_is_forum,omitempty"`
	ChatIsCreated      bool                   `json:"chat_is_created,omitempty"`
	BotIsMember        bool                   `json:"bot_is_member,omitempty"`
	UserAdminRights    *BotRequestAdminRights `json:"user_admin_rights,omitempty"`
	BotAdminRights     *BotRequestAdminRights `json:"bot_admin_rights,omitempty"`
}

// MarkupButton is one message keyboard button. The reply keyboard currently accepts only plain text buttons;
// the inline keyboard currently accepts callback/url.
type MarkupButton struct {
	Type MarkupButtonType `json:"type"`
	Text string           `json:"text"`
	// Style is one of primary/danger/success. Empty means the client default.
	Style MarkupButtonStyle `json:"style,omitempty"`
	// IconCustomEmojiID is the optional custom emoji rendered before Text.
	IconCustomEmojiID int64 `json:"icon_custom_emoji_id,omitempty"`
	// Data is used only by callback: raw bytes (including 0x00/non-UTF-8/high bits). json automatically base64
	// encodes/decodes, guaranteeing a byte-level round-trip through the JSONB column (updateBotCallbackQuery.data must be verbatim).
	Data []byte `json:"data,omitempty"`
	// URL is used only by url.
	URL string `json:"url,omitempty"`
	// Login URL-only fields. LoginBotUserID=0 means the sending bot until the
	// RPC/Bot API edge resolves it. LoginBotUsername is input-only and must be
	// cleared before persistence.
	ForwardText        string `json:"forward_text,omitempty"`
	LoginBotUserID     int64  `json:"login_bot_user_id,omitempty"`
	LoginBotUsername   string `json:"login_bot_username,omitempty"`
	RequestWriteAccess bool   `json:"request_write_access,omitempty"`
	// RequiresPassword is used only by callback (keyboardButtonCallback.requires_password,
	// the P3 stub for 2FA SRP verification).
	RequiresPassword bool `json:"requires_password,omitempty"`
	// PollType is empty, "regular", or "quiz" for request_poll.
	PollType string `json:"poll_type,omitempty"`
	// ButtonID and request-peer fields preserve Bot API request_id and the
	// client-side chooser shape. RequestPeerType is user/chat/broadcast.
	ButtonID          int                   `json:"button_id,omitempty"`
	RequestPeerType   string                `json:"request_peer_type,omitempty"`
	MaxQuantity       int                   `json:"max_quantity,omitempty"`
	NameRequested     bool                  `json:"name_requested,omitempty"`
	UsernameRequested bool                  `json:"username_requested,omitempty"`
	PhotoRequested    bool                  `json:"photo_requested,omitempty"`
	RequestPeerFilter *BotRequestPeerFilter `json:"request_peer_filter,omitempty"`
	Query             string                `json:"query,omitempty"`
	SamePeer          bool                  `json:"same_peer,omitempty"`
	PeerTypes         []string              `json:"peer_types,omitempty"`
	CopyText          string                `json:"copy_text,omitempty"`
}

// MessageReplyMarkupType identifies the mutually exclusive ReplyMarkup constructors.
type MessageReplyMarkupType string

const (
	MessageReplyMarkupInline     MessageReplyMarkupType = "inline"
	MessageReplyMarkupKeyboard   MessageReplyMarkupType = "keyboard"
	MessageReplyMarkupHide       MessageReplyMarkupType = "hide"
	MessageReplyMarkupForceReply MessageReplyMarkupType = "force_reply"
)

// MessageReplyMarkup is the protocol-neutral reply markup snapshot carried by a message. An empty Type with a
// non-empty Inline means a valid inline keyboard persisted before 0110, which Kind interprets as inline.
type MessageReplyMarkup struct {
	Type        MessageReplyMarkupType `json:"type,omitempty"`
	Inline      [][]MarkupButton       `json:"inline,omitempty"`
	Keyboard    [][]MarkupButton       `json:"keyboard,omitempty"`
	Resize      bool                   `json:"resize,omitempty"`
	SingleUse   bool                   `json:"single_use,omitempty"`
	Selective   bool                   `json:"selective,omitempty"`
	Persistent  bool                   `json:"persistent,omitempty"`
	Placeholder string                 `json:"placeholder,omitempty"`
}

// Kind returns the markup constructor; it is compatible with an already-persisted inline snapshot without a Type.
func (m *MessageReplyMarkup) Kind() MessageReplyMarkupType {
	if m == nil {
		return ""
	}
	if m.Type != "" {
		return m.Type
	}
	if len(m.Inline) > 0 {
		return MessageReplyMarkupInline
	}
	return ""
}

// IsReplyKeyboardFamily reports whether the markup controls the reply keyboard below the input field.
func (m *MessageReplyMarkup) IsReplyKeyboardFamily() bool {
	switch m.Kind() {
	case MessageReplyMarkupKeyboard, MessageReplyMarkupHide, MessageReplyMarkupForceReply:
		return true
	default:
		return false
	}
}

// IsZero reports whether the markup is empty (no buttons). An empty markup writes no wire flag and is not persisted.
func (m *MessageReplyMarkup) IsZero() bool {
	if m == nil {
		return true
	}
	switch m.Kind() {
	case MessageReplyMarkupInline:
		for _, row := range m.Inline {
			if len(row) > 0 {
				return false
			}
		}
		return true
	case MessageReplyMarkupKeyboard:
		for _, row := range m.Keyboard {
			if len(row) > 0 {
				return false
			}
		}
		return true
	case MessageReplyMarkupHide, MessageReplyMarkupForceReply:
		return false
	default:
		return true
	}
}

// ValidateReplyMarkup validates the markup constructor, structure and buttons; validation must precede persist (I9).
// An empty inline markup is valid (treated as clearing/no keyboard).
func ValidateReplyMarkup(m *MessageReplyMarkup) error {
	if m == nil {
		return nil
	}
	kind := m.Kind()
	if kind == "" {
		if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" {
			return ErrButtonInvalid
		}
		return nil
	}
	switch kind {
	case MessageReplyMarkupInline:
		if len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" {
			return ErrButtonInvalid
		}
		return validateMarkupRows(m.Inline, false)
	case MessageReplyMarkupKeyboard:
		if len(m.Inline) != 0 || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen {
			return ErrButtonInvalid
		}
		if len(m.Keyboard) == 0 {
			return ErrButtonInvalid
		}
		return validateMarkupRows(m.Keyboard, true)
	case MessageReplyMarkupHide:
		if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Persistent || m.Placeholder != "" {
			return ErrButtonInvalid
		}
		return nil
	case MessageReplyMarkupForceReply:
		if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.Persistent || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen {
			return ErrButtonInvalid
		}
		return nil
	default:
		return ErrButtonInvalid
	}
}

func validateMarkupRows(rows [][]MarkupButton, replyKeyboard bool) error {
	if len(rows) > MaxMarkupRows {
		return ErrButtonInvalid
	}
	total := 0
	for _, row := range rows {
		if len(row) == 0 || len(row) > MaxMarkupButtonsPerRow {
			return ErrButtonInvalid
		}
		total += len(row)
		if total > MaxMarkupButtonsTotal {
			return ErrButtonInvalid
		}
		for i := range row {
			if err := validateMarkupButton(row[i], replyKeyboard); err != nil {
				return err
			}
		}
	}
	return nil
}

func validateMarkupButton(b MarkupButton, replyKeyboard bool) error {
	text := strings.TrimSpace(b.Text)
	if text == "" || utf8.RuneCountInString(b.Text) > MaxMarkupButtonTextLen {
		return ErrButtonInvalid
	}
	switch b.Style {
	case "", MarkupButtonStylePrimary, MarkupButtonStyleDanger, MarkupButtonStyleSuccess:
	default:
		return ErrButtonInvalid
	}
	if b.IconCustomEmojiID < 0 {
		return ErrButtonInvalid
	}
	if replyKeyboard {
		if utf8.RuneCountInString(b.Text) > MaxReplyKeyboardButtonTextLen {
			return ErrButtonInvalid
		}
		switch b.Type {
		case MarkupButtonText, MarkupButtonRequestPhone, MarkupButtonRequestLocation:
		case MarkupButtonRequestPoll:
			if b.PollType != "" && b.PollType != "regular" && b.PollType != "quiz" {
				return ErrButtonInvalid
			}
		case MarkupButtonRequestPeer:
			if b.ButtonID == 0 || b.MaxQuantity < 1 || b.MaxQuantity > 10 ||
				(b.RequestPeerType != "user" && b.RequestPeerType != "chat" && b.RequestPeerType != "broadcast") {
				return ErrButtonInvalid
			}
			if b.RequestPeerFilter != nil {
				filter := b.RequestPeerFilter
				if b.RequestPeerType == "user" {
					if filter.ChatHasUsernameSet || filter.ChatIsForumSet || filter.ChatIsCreated || filter.BotIsMember ||
						filter.UserAdminRights != nil || filter.BotAdminRights != nil {
						return ErrButtonInvalid
					}
				} else {
					if filter.UserIsBotSet || filter.UserIsPremiumSet ||
						(b.RequestPeerType == "broadcast" && (filter.ChatIsForumSet || filter.BotIsMember)) {
						return ErrButtonInvalid
					}
				}
			}
		case MarkupButtonSimpleWebView:
			if err := validateButtonURL(b.URL); err != nil {
				return err
			}
		default:
			return ErrButtonTypeInvalid
		}
		if len(b.Data) != 0 || b.RequiresPassword || b.Query != "" || b.SamePeer || len(b.PeerTypes) != 0 || b.CopyText != "" {
			return ErrButtonInvalid
		}
		return nil
	}
	switch b.Type {
	case MarkupButtonCallback:
		if len(b.Data) > MaxCallbackDataLen {
			return ErrButtonDataInvalid
		}
	case MarkupButtonURL:
		if err := validateButtonURL(b.URL); err != nil {
			return err
		}
	case MarkupButtonLoginURL:
		if err := validateLoginButtonURL(b.URL); err != nil {
			return err
		}
		if b.ButtonID < 0 || b.LoginBotUserID < 0 || utf8.RuneCountInString(b.ForwardText) > MaxReplyKeyboardButtonTextLen ||
			utf8.RuneCountInString(b.LoginBotUsername) > 64 {
			return ErrButtonInvalid
		}
	case MarkupButtonWebView:
		if err := validateButtonURL(b.URL); err != nil {
			return err
		}
	case MarkupButtonSwitchInline:
		if utf8.RuneCountInString(b.Query) > 256 {
			return ErrButtonInvalid
		}
	case MarkupButtonCopy:
		if b.CopyText == "" || utf8.RuneCountInString(b.CopyText) > 256 {
			return ErrButtonInvalid
		}
	case MarkupButtonBuy:
		if len(b.Data) != 0 || b.URL != "" || b.Query != "" || b.CopyText != "" ||
			b.RequiresPassword || b.LoginBotUserID != 0 || b.ButtonID != 0 {
			return ErrButtonInvalid
		}
	default:
		// P3-unimplemented types such as webview/game/url_auth/request_*: reject, never ship a half-implementation.
		return ErrButtonTypeInvalid
	}
	return nil
}

func validateButtonURL(raw string) error {
	raw = strings.TrimSpace(raw)
	if raw == "" || len(raw) > MaxBotMenuButtonURLLen {
		return ErrButtonURLInvalid
	}
	u, err := url.Parse(raw)
	if err != nil || u.Scheme != "https" || u.Host == "" {
		return ErrButtonURLInvalid
	}
	return nil
}

// validateLoginButtonURL performs only the protocol-shape validation shared by
// Bot API and MTProto input buttons. The Telegram Login service remains the
// authority for the deployment policy: HTTP is accepted here as a protocol
// shape, then allowed only when the Login HTTP switch is enabled and the exact
// origin is registered.
func validateLoginButtonURL(raw string) error {
	raw = strings.TrimSpace(raw)
	if raw == "" || len(raw) > MaxBotMenuButtonURLLen {
		return ErrButtonURLInvalid
	}
	u, err := url.Parse(raw)
	if err != nil || u.Host == "" || u.User != nil {
		return ErrButtonURLInvalid
	}
	scheme := strings.ToLower(u.Scheme)
	if scheme != "http" && scheme != "https" {
		return ErrButtonURLInvalid
	}
	return nil
}

// BotCallbackAnswer is a bot's answer to one callback query (setBotCallbackAnswer →
// unblocks a waiting getBotCallbackAnswer).
type BotCallbackAnswer struct {
	Alert     bool
	Message   string
	URL       string
	CacheTime int
}