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:
A 2026-07-19 20:38:48 +08:00
parent 0c99ae0a9d
commit bf965f610c
80 changed files with 7212 additions and 349 deletions

View file

@ -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 侧回答

View file

@ -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
}

View 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
}

View file

@ -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,
}
}

View file

@ -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 是私聊服务消息动作的协议中立表示。

View file

@ -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

View file

@ -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

View file

@ -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")
}
}

View file

@ -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 构造器没有