feat: sync bot keyboards and callbacks
Sync telesrv b96f2dd (feat(bot): complete keyboards callbacks and durable delivery). Skipped private docs and preserved public README files per sync rules; normalized the appearance seed log label for public naming.
This commit is contained in:
parent
0c99ae0a9d
commit
bf965f610c
80 changed files with 7212 additions and 349 deletions
|
|
@ -18,6 +18,10 @@ const (
|
|||
MaxCallbackDataLen = 64
|
||||
// MaxMarkupButtonTextLen 是按钮文本长度上限(rune 计数)。
|
||||
MaxMarkupButtonTextLen = 256
|
||||
// MaxReplyKeyboardButtonTextLen 对齐 Bot API KeyboardButton 的 1-64 字符约束。
|
||||
MaxReplyKeyboardButtonTextLen = 64
|
||||
// MaxReplyKeyboardPlaceholderLen 是 reply keyboard / force reply 输入框占位符上限。
|
||||
MaxReplyKeyboardPlaceholderLen = 64
|
||||
// MaxBotCallbackAnswerLen 是 callback answer 弹窗/toast 文本上限。
|
||||
MaxBotCallbackAnswerLen = 200
|
||||
// MaxStartParamLen 是 messages.startBot 深链 payload 上限(对齐官方 64)。
|
||||
|
|
@ -38,20 +42,81 @@ var (
|
|||
ErrStartParamInvalid = errors.New("start param invalid")
|
||||
)
|
||||
|
||||
// MarkupButtonType 标识 P3 支持的 inline 按钮类型。
|
||||
// MarkupButtonType 标识消息键盘按钮类型。
|
||||
type MarkupButtonType string
|
||||
|
||||
const (
|
||||
// MarkupButtonText 是 reply keyboard 的普通文本按钮;点击后客户端发送标准文本消息。
|
||||
MarkupButtonText MarkupButtonType = "text"
|
||||
// MarkupButtonCallback 是 keyboardButtonCallback(点击触发 getBotCallbackAnswer)。
|
||||
MarkupButtonCallback MarkupButtonType = "callback"
|
||||
// MarkupButtonURL 是 keyboardButtonUrl(点击打开链接)。
|
||||
MarkupButtonURL MarkupButtonType = "url"
|
||||
MarkupButtonURL MarkupButtonType = "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"
|
||||
)
|
||||
|
||||
// MarkupButton 是一颗 inline keyboard 按钮(P3 仅 callback/url)。
|
||||
// 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 是一颗消息键盘按钮。reply keyboard 当前只接受普通文本按钮;
|
||||
// inline keyboard 当前接受 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 仅 callback 使用:原始字节(含 0x00/非 UTF-8/高位)。json 自动 base64
|
||||
// 编解码,保证经 JSONB 列字节级 round-trip(updateBotCallbackQuery.data 须原样)。
|
||||
Data []byte `json:"data,omitempty"`
|
||||
|
|
@ -60,11 +125,68 @@ type MarkupButton struct {
|
|||
// RequiresPassword 仅 callback 使用(keyboardButtonCallback.requires_password,
|
||||
// 2FA SRP 校验 P3 stub)。
|
||||
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"`
|
||||
}
|
||||
|
||||
// MessageReplyMarkup 是消息携带的 inline keyboard 快照(P3 仅 ReplyInlineMarkup)。
|
||||
// MessageReplyMarkupType 标识互斥的 ReplyMarkup constructor。
|
||||
type MessageReplyMarkupType string
|
||||
|
||||
const (
|
||||
MessageReplyMarkupInline MessageReplyMarkupType = "inline"
|
||||
MessageReplyMarkupKeyboard MessageReplyMarkupType = "keyboard"
|
||||
MessageReplyMarkupHide MessageReplyMarkupType = "hide"
|
||||
MessageReplyMarkupForceReply MessageReplyMarkupType = "force_reply"
|
||||
)
|
||||
|
||||
// MessageReplyMarkup 是消息携带的协议中立 reply markup 快照。Type 为空且 Inline
|
||||
// 非空表示 0110 之前已经持久化的合法 inline keyboard,Kind 会将其解释为 inline。
|
||||
type MessageReplyMarkup struct {
|
||||
Inline [][]MarkupButton `json:"inline,omitempty"`
|
||||
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 返回 markup constructor;兼容已落库的无 Type inline 快照。
|
||||
func (m *MessageReplyMarkup) Kind() MessageReplyMarkupType {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if m.Type != "" {
|
||||
return m.Type
|
||||
}
|
||||
if len(m.Inline) > 0 {
|
||||
return MessageReplyMarkupInline
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsReplyKeyboardFamily 报告 markup 是否会控制输入框下方的 reply keyboard。
|
||||
func (m *MessageReplyMarkup) IsReplyKeyboardFamily() bool {
|
||||
switch m.Kind() {
|
||||
case MessageReplyMarkupKeyboard, MessageReplyMarkupHide, MessageReplyMarkupForceReply:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsZero 报告 markup 是否为空(无任何按钮)。空 markup 不写 wire flag、不入库。
|
||||
|
|
@ -72,26 +194,77 @@ func (m *MessageReplyMarkup) IsZero() bool {
|
|||
if m == nil {
|
||||
return true
|
||||
}
|
||||
for _, row := range m.Inline {
|
||||
if len(row) > 0 {
|
||||
return false
|
||||
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
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidateReplyMarkup 校验 inline keyboard 结构与各按钮,校验须先于落库(I9)。
|
||||
// 空 markup 合法(视为清空/无键盘)。
|
||||
// ValidateReplyMarkup 校验 markup constructor、结构与按钮,校验须先于落库(I9)。
|
||||
// 空 inline markup 合法(视为清空/无键盘)。
|
||||
func ValidateReplyMarkup(m *MessageReplyMarkup) error {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
if len(m.Inline) > MaxMarkupRows {
|
||||
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 m.Inline {
|
||||
if len(row) > MaxMarkupButtonsPerRow {
|
||||
for _, row := range rows {
|
||||
if len(row) == 0 || len(row) > MaxMarkupButtonsPerRow {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
total += len(row)
|
||||
|
|
@ -99,7 +272,7 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error {
|
|||
return ErrButtonInvalid
|
||||
}
|
||||
for i := range row {
|
||||
if err := validateMarkupButton(row[i]); err != nil {
|
||||
if err := validateMarkupButton(row[i], replyKeyboard); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -107,11 +280,60 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateMarkupButton(b MarkupButton) error {
|
||||
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 {
|
||||
|
|
@ -121,6 +343,18 @@ func validateMarkupButton(b MarkupButton) error {
|
|||
if err := validateButtonURL(b.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
default:
|
||||
// webview/game/url_auth/request_* 等 P3 未实现类型:拒绝,绝不半实现下发。
|
||||
return ErrButtonTypeInvalid
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue