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
|
|
@ -204,15 +204,19 @@ type BotAttachMenuState struct {
|
|||
|
||||
// BotRequestedWebViewButton 是 bots.requestWebViewButton 创建的 request-peer 上下文。
|
||||
type BotRequestedWebViewButton struct {
|
||||
WebAppReqID string
|
||||
BotUserID int64
|
||||
UserID int64
|
||||
ButtonID int
|
||||
Text string
|
||||
PeerType string
|
||||
MaxQuantity int
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
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 是 custom method 的 pending 记录。没有 bot 侧回答
|
||||
|
|
|
|||
|
|
@ -6,8 +6,32 @@ type BotAPIUpdateKind string
|
|||
const (
|
||||
BotAPIUpdateMessage BotAPIUpdateKind = "message"
|
||||
BotAPIUpdateEditedMessage BotAPIUpdateKind = "edited_message"
|
||||
BotAPIUpdateCallbackQuery BotAPIUpdateKind = "callback_query"
|
||||
)
|
||||
|
||||
// BotCallbackQuery is the protocol-neutral payload shared by MTProto
|
||||
// updateBotCallbackQuery and the HTTP Bot API CallbackQuery projection.
|
||||
type BotCallbackQuery struct {
|
||||
ID int64
|
||||
BotUserID int64
|
||||
UserID int64
|
||||
Peer Peer
|
||||
MessageID int
|
||||
ChatInstance int64
|
||||
Data []byte
|
||||
InlineMessage *BotInlineMessageID
|
||||
}
|
||||
|
||||
// BotInlineMessageID is the domain-only shape of inputBotInlineMessageID64.
|
||||
// It can be projected both to MTProto and to Bot API's opaque
|
||||
// inline_message_id without leaking tg types into the store boundary.
|
||||
type BotInlineMessageID struct {
|
||||
DCID int
|
||||
OwnerID int64
|
||||
ID int
|
||||
AccessHash int64
|
||||
}
|
||||
|
||||
// BotAPIUpdate is a durable Bot API update cursor. ID is the Bot API update_id
|
||||
// and is global across all bots, matching Telegram Bot API's monotonic offset
|
||||
// contract without reusing MTProto pts from user/channel logs.
|
||||
|
|
@ -19,6 +43,7 @@ type BotAPIUpdate struct {
|
|||
MessageID int
|
||||
SourcePts int
|
||||
Date int
|
||||
Callback *BotCallbackQuery
|
||||
}
|
||||
|
||||
// EnqueueBotAPIUpdateRequest describes a message-like update that should be
|
||||
|
|
@ -30,4 +55,5 @@ type EnqueueBotAPIUpdateRequest struct {
|
|||
MessageID int
|
||||
SourcePts int
|
||||
Date int
|
||||
Callback *BotCallbackQuery
|
||||
}
|
||||
|
|
|
|||
21
internal/domain/botapi_webhook.go
Normal file
21
internal/domain/botapi_webhook.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// BotAPIWebhook is durable delivery configuration and observable retry state.
|
||||
// The token secret is never stored here: authentication remains owned by BotProfile.
|
||||
type BotAPIWebhook struct {
|
||||
BotUserID int64
|
||||
URL string
|
||||
SecretToken string
|
||||
MaxConnections int
|
||||
AllowedUpdates []BotAPIUpdateKind
|
||||
// AllowedUpdatesSet distinguishes an explicitly supplied (possibly empty)
|
||||
// setWebhook parameter from omission, which must preserve the previous
|
||||
// getUpdates/setWebhook policy atomically at the store boundary.
|
||||
AllowedUpdatesSet bool
|
||||
FailureCount int
|
||||
LastErrorDate int
|
||||
LastErrorMessage string
|
||||
NextAttemptAt time.Time
|
||||
}
|
||||
|
|
@ -188,20 +188,23 @@ const (
|
|||
|
||||
// ChannelAdminRights is a domain-only representation of Telegram admin rights.
|
||||
type ChannelAdminRights struct {
|
||||
ChangeInfo bool
|
||||
PostMessages bool
|
||||
EditMessages bool
|
||||
DeleteMessages bool
|
||||
PostStories bool
|
||||
EditStories bool
|
||||
DeleteStories bool
|
||||
BanUsers bool
|
||||
InviteUsers bool
|
||||
PinMessages bool
|
||||
AddAdmins bool
|
||||
ManageCall bool
|
||||
Anonymous bool
|
||||
ManageRanks bool
|
||||
ChangeInfo bool
|
||||
PostMessages bool
|
||||
EditMessages bool
|
||||
DeleteMessages bool
|
||||
PostStories bool
|
||||
EditStories bool
|
||||
DeleteStories bool
|
||||
BanUsers bool
|
||||
InviteUsers bool
|
||||
PinMessages bool
|
||||
AddAdmins bool
|
||||
ManageCall bool
|
||||
ManageChat bool
|
||||
ManageTopics bool
|
||||
Anonymous bool
|
||||
ManageRanks bool
|
||||
ManageLinkedPeers bool
|
||||
// ManageDirectMessages 对应 TL ChatAdminRights.manage_direct_messages(flags.17)。母广播频道的
|
||||
// 管理员据此被客户端授予 monoforum(频道私信)容器的 MonoforumAdmin 身份;creator 走 amCreator 旁路。
|
||||
ManageDirectMessages bool
|
||||
|
|
@ -210,19 +213,22 @@ type ChannelAdminRights struct {
|
|||
// CreatorChannelAdminRights returns the full rights set clients expect on creator projections.
|
||||
func CreatorChannelAdminRights() ChannelAdminRights {
|
||||
return ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
ManageRanks: true,
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
ManageChat: true,
|
||||
ManageTopics: true,
|
||||
ManageRanks: true,
|
||||
ManageLinkedPeers: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -610,6 +610,23 @@ type MessageWebViewDataAction struct {
|
|||
type MessageRequestedPeerAction struct {
|
||||
ButtonID int `json:"button_id"`
|
||||
Peers []Peer `json:"peers"`
|
||||
// Details is the immutable, permission-gated snapshot delivered to the bot.
|
||||
// It is kept separate from Peers because the sender-side MTProto action only
|
||||
// exposes peer identities, while the bot-side/Bot API view may additionally
|
||||
// expose the requested name, username, and profile photo.
|
||||
Details []MessageRequestedPeerDetails `json:"details,omitempty"`
|
||||
NameRequested bool `json:"name_requested,omitempty"`
|
||||
UsernameRequested bool `json:"username_requested,omitempty"`
|
||||
PhotoRequested bool `json:"photo_requested,omitempty"`
|
||||
}
|
||||
|
||||
type MessageRequestedPeerDetails struct {
|
||||
Peer Peer `json:"peer"`
|
||||
FirstName string `json:"first_name,omitempty"`
|
||||
LastName string `json:"last_name,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
}
|
||||
|
||||
// MessageServiceAction 是私聊服务消息动作的协议中立表示。
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ type Message struct {
|
|||
// (🎉/👍 等),发送方与接收方双盒持同一非零值并各自播放一次;非特效消息恒 0。
|
||||
// 转发不携带特效(新消息恒 0)。仅私聊;群/频道不渲染。
|
||||
Effect int64
|
||||
// ReplyMarkup 是 bot 消息携带的 inline keyboard 快照(P3)。仅 bot 出站消息可
|
||||
// ReplyMarkup 是 bot 消息携带的 reply/inline keyboard 快照。仅 bot 出站消息可
|
||||
// 非空;普通用户消息恒 nil(发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
// RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。
|
||||
|
|
@ -283,7 +283,7 @@ type SendPrivateTextRequest struct {
|
|||
// BusinessAutomationKind is internal app-layer metadata used to suppress
|
||||
// recursive greeting/away automation for server-generated replies.
|
||||
BusinessAutomationKind BusinessAutomationKind
|
||||
// ReplyMarkup 是 bot 出站消息的 inline keyboard 快照(P3);普通用户发送恒 nil。
|
||||
// ReplyMarkup 是 bot 出站消息的 reply/inline keyboard 快照;普通用户发送恒 nil。
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
// RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。
|
||||
RichMessage *MessageRichMessage
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -26,7 +26,20 @@ func TestValidateReplyMarkup(t *testing.T) {
|
|||
{"url http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "http://example.com"}}}}, ErrButtonURLInvalid},
|
||||
{"url javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid},
|
||||
{"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid},
|
||||
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "webview", Text: "x"}}}}, ErrButtonTypeInvalid},
|
||||
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid},
|
||||
{"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil},
|
||||
{"reply keyboard semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Delete", Style: MarkupButtonStyleDanger, IconCustomEmojiID: 123}}}}, nil},
|
||||
{"inline semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Inline: [][]MarkupButton{{{Type: MarkupButtonCallback, Text: "Confirm", Data: []byte("yes"), Style: MarkupButtonStyleSuccess}}}}, nil},
|
||||
{"unknown semantic style bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", Style: "rainbow"}}}}, ErrButtonInvalid},
|
||||
{"negative custom emoji bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", IconCustomEmojiID: -1}}}}, ErrButtonInvalid},
|
||||
{"reply keyboard callback bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{cb("wrong", []byte("d"))}}}, ErrButtonTypeInvalid},
|
||||
{"reply keyboard empty bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard}, ErrButtonInvalid},
|
||||
{"reply keyboard placeholder too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Placeholder: strings.Repeat("p", MaxReplyKeyboardPlaceholderLen+1)}, ErrButtonInvalid},
|
||||
{"reply keyboard text too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: strings.Repeat("x", MaxReplyKeyboardButtonTextLen+1)}}}}, ErrButtonInvalid},
|
||||
{"hide keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupHide, Selective: true}, nil},
|
||||
{"force reply ok", &MessageReplyMarkup{Type: MessageReplyMarkupForceReply, SingleUse: true, Placeholder: "Answer"}, nil},
|
||||
{"missing keyboard constructor", &MessageReplyMarkup{Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid},
|
||||
{"inline constructor with keyboard payload", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -74,4 +87,10 @@ func TestMessageReplyMarkupIsZero(t *testing.T) {
|
|||
if (&MessageReplyMarkup{Inline: [][]MarkupButton{{cb("x", nil)}}}).IsZero() {
|
||||
t.Fatal("markup with a button must not be zero")
|
||||
}
|
||||
if (&MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "x"}}}}).IsZero() {
|
||||
t.Fatal("reply keyboard with a button must not be zero")
|
||||
}
|
||||
if (&MessageReplyMarkup{Type: MessageReplyMarkupHide}).IsZero() {
|
||||
t.Fatal("hide keyboard constructor must not be zero")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ const (
|
|||
UpdateEventReadChannelDiscussionOutbox UpdateEventType = "read_channel_discussion_outbox"
|
||||
UpdateEventReadMessageContents UpdateEventType = "read_message_contents"
|
||||
UpdateEventEditMessage UpdateEventType = "edit_message"
|
||||
// UpdateEventBotCallbackQuery 仅用于 Bot API 专用 update_id 队列投影;不写账号
|
||||
// pts/difference/outbox。
|
||||
UpdateEventBotCallbackQuery UpdateEventType = "bot_callback_query"
|
||||
// UpdateEventWebPage 映射 updateWebPage:异步解析完成后把消息里的 pending 链接预览
|
||||
// 占位就地替换为已解析卡片。携带账号 pts(非 LacksWirePts),消息快照经 box JOIN 重建,
|
||||
// 故 difference/dispatch 与 edit_message 同走通用消息事件路径,仅 tg 投影构造器不同。
|
||||
|
|
@ -110,6 +113,7 @@ type UpdateEvent struct {
|
|||
QuickReplies []QuickReply
|
||||
QuickReply QuickReply
|
||||
QuickReplyMessage QuickReplyMessage
|
||||
BotCallbackQuery *BotCallbackQuery
|
||||
}
|
||||
|
||||
// LacksWirePts 表示该事件占用了账号 pts,但它对应的 TL update 构造器没有
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue