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

@ -1,6 +1,7 @@
package rpc
import (
"context"
"errors"
"github.com/iamxvbaba/td/tg"
@ -9,9 +10,30 @@ import (
"telesrv/internal/domain"
)
// validateReplyMarkupForPeer enforces the Bot API/TL boundary that reply keyboards control
// a chat input field and are not supported in broadcast channels. Inline keyboards remain
// valid in both megagroups and broadcasts.
func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, peer domain.Peer, markup *domain.MessageReplyMarkup) error {
if markup == nil || !markup.IsReplyKeyboardFamily() || peer.Type != domain.PeerTypeChannel {
return nil
}
if r == nil || r.deps.Channels == nil {
return channelInvalidErr(domain.ErrChannelInvalid)
}
view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID)
if err != nil {
return channelInvalidErr(err)
}
if view.Channel.Broadcast && !view.Channel.Megagroup {
return replyMarkupInvalidErr()
}
return nil
}
// P3 reply_markup 错误码(对齐官方)。
func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") }
func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") }
func buttonTypeInvalidErr() error { return tgerr.New(400, "BUTTON_TYPE_INVALID") }
func buttonURLInvalidErr() error { return tgerr.New(400, "BUTTON_URL_INVALID") }
// replyMarkupErr 把 domain 校验错误映射为客户端错误码。
@ -21,18 +43,21 @@ func replyMarkupErr(err error) error {
return buttonDataInvalidErr()
case errors.Is(err, domain.ErrButtonURLInvalid):
return buttonURLInvalidErr()
case errors.Is(err, domain.ErrButtonInvalid), errors.Is(err, domain.ErrButtonTypeInvalid):
case errors.Is(err, domain.ErrButtonTypeInvalid):
return buttonTypeInvalidErr()
case errors.Is(err, domain.ErrButtonInvalid):
return buttonInvalidErr()
default:
return replyMarkupInvalidErr()
}
}
// domainReplyMarkupForSender 解析入站 reply_markup。P3 语义:
// domainReplyMarkupForSender 解析只能携带 inline keyboard 的入站 reply_markupinline
// result / edit 路径)。普通消息发送使用 domainOutgoingReplyMarkupForSender。
// 语义:
// - 仅 bot 账号下发的 markup 被接受;非 bot 一律丢弃(返回 nil不报错——对齐
// 官方「普通用户 markup 无效」I1
// - 仅 ReplyInlineMarkup 被处理reply keyboard 家族(自定义键盘/隐藏/强制回复)
// P3 不支持,静默丢弃(不报错,避免破坏 bot 发送;记 P4
// - 仅 ReplyInlineMarkup 被处理bot 的 reply keyboard 家族在这些上下文中显式拒绝。
// - inline 行内按钮仅 callback / url其它按钮类型webview/game/url_auth/
// request_* 等)→ ErrButtonTypeInvalid拒绝整条发送绝不半实现下发
// - data≤64B、行/按钮上限、url https 由 domain.ValidateReplyMarkup 校验。
@ -42,8 +67,7 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*
}
inline, ok := markup.(*tg.ReplyInlineMarkup)
if !ok {
// reply keyboard / hide / force-replyP3 不支持,丢弃。
return nil, nil
return nil, domain.ErrButtonTypeInvalid
}
parsed, err := domainInlineMarkup(inline)
if err != nil {
@ -58,8 +82,99 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*
return parsed, nil
}
// domainOutgoingReplyMarkupForSender 解析普通 sendMessage/sendMedia 的完整 reply markup。
// 非 bot 携带 markup 仍按官方权限边界静默丢弃bot 的未知/未实现按钮则拒绝整条消息,
// 避免客户端看到一个被服务端悄悄改形的键盘。
func domainOutgoingReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*domain.MessageReplyMarkup, error) {
if markup == nil || !senderIsBot {
return nil, nil
}
switch v := markup.(type) {
case *tg.ReplyInlineMarkup:
return domainReplyMarkupForSender(v, true)
case *tg.ReplyKeyboardMarkup:
out := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupKeyboard,
Keyboard: make([][]domain.MarkupButton, 0, len(v.Rows)),
Resize: v.Resize,
SingleUse: v.SingleUse,
Selective: v.Selective,
Persistent: v.Persistent,
Placeholder: v.Placeholder,
}
for _, row := range v.Rows {
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
for _, button := range row.Buttons {
parsed, err := domainReplyKeyboardButton(button)
if err != nil {
return nil, err
}
domainRow = append(domainRow, parsed)
}
out.Keyboard = append(out.Keyboard, domainRow)
}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, err
}
return out, nil
case *tg.ReplyKeyboardHide:
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupHide, Selective: v.Selective}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, err
}
return out, nil
case *tg.ReplyKeyboardForceReply:
out := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupForceReply,
SingleUse: v.SingleUse,
Selective: v.Selective,
Placeholder: v.Placeholder,
}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, err
}
return out, nil
default:
return nil, domain.ErrButtonTypeInvalid
}
}
func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButton, error) {
style, icon, err := domainMarkupButtonStyle(button)
if err != nil {
return domain.MarkupButton{}, err
}
base := domain.MarkupButton{Style: style, IconCustomEmojiID: icon}
switch b := button.(type) {
case *tg.KeyboardButton:
base.Type, base.Text = domain.MarkupButtonText, b.Text
case *tg.KeyboardButtonRequestPhone:
base.Type, base.Text = domain.MarkupButtonRequestPhone, b.Text
case *tg.KeyboardButtonRequestGeoLocation:
base.Type, base.Text = domain.MarkupButtonRequestLocation, b.Text
case *tg.KeyboardButtonRequestPoll:
base.Type, base.Text = domain.MarkupButtonRequestPoll, b.Text
if quiz, ok := b.GetQuiz(); ok {
if quiz {
base.PollType = "quiz"
} else {
base.PollType = "regular"
}
}
case *tg.KeyboardButtonRequestPeer:
base.Type, base.Text = domain.MarkupButtonRequestPeer, b.Text
base.ButtonID, base.MaxQuantity = b.ButtonID, b.MaxQuantity
base.RequestPeerType, base.RequestPeerFilter = domainRequestPeerFilter(b.PeerType)
case *tg.KeyboardButtonSimpleWebView:
base.Type, base.Text, base.URL = domain.MarkupButtonSimpleWebView, b.Text, b.URL
default:
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid
}
return base, nil
}
func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarkup, error) {
out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))}
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))}
for _, row := range inline.Rows {
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
for _, btn := range row.Buttons {
@ -75,31 +190,119 @@ func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarku
}
func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) {
style, icon, err := domainMarkupButtonStyle(btn)
if err != nil {
return domain.MarkupButton{}, err
}
switch b := btn.(type) {
case *tg.KeyboardButtonCallback:
return domain.MarkupButton{
Type: domain.MarkupButtonCallback,
Text: b.Text,
Data: append([]byte(nil), b.Data...),
RequiresPassword: b.RequiresPassword,
Type: domain.MarkupButtonCallback,
Text: b.Text,
Style: style,
IconCustomEmojiID: icon,
Data: append([]byte(nil), b.Data...),
RequiresPassword: b.RequiresPassword,
}, nil
case *tg.KeyboardButtonURL:
return domain.MarkupButton{
Type: domain.MarkupButtonURL,
Text: b.Text,
URL: b.URL,
Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL,
Style: style, IconCustomEmojiID: icon,
}, nil
case *tg.KeyboardButtonWebView:
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: b.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon}, nil
case *tg.KeyboardButtonSwitchInline:
peerTypes, err := preparedInlinePeerTypesFromTG(b.PeerTypes)
if err != nil {
return domain.MarkupButton{}, domain.ErrButtonInvalid
}
return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: b.Text, Query: b.Query, SamePeer: b.SamePeer, PeerTypes: peerTypes, Style: style, IconCustomEmojiID: icon}, nil
case *tg.KeyboardButtonCopy:
return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: b.Text, CopyText: b.CopyText, Style: style, IconCustomEmojiID: icon}, nil
default:
// webview/game/url_auth/request_*/switch_inline/buy 等 P3 未实现按钮类型。
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid
}
}
// tgReplyMarkup 把存储的 inline keyboard 快照还原为 tg.ReplyInlineMarkup。
func domainMarkupButtonStyle(btn tg.KeyboardButtonClass) (domain.MarkupButtonStyle, int64, error) {
style, ok := btn.GetStyle()
if !ok {
return "", 0, nil
}
colors := 0
var out domain.MarkupButtonStyle
if style.GetBgPrimary() {
colors++
out = domain.MarkupButtonStylePrimary
}
if style.GetBgDanger() {
colors++
out = domain.MarkupButtonStyleDanger
}
if style.GetBgSuccess() {
colors++
out = domain.MarkupButtonStyleSuccess
}
icon, hasIcon := style.GetIcon()
if colors > 1 || (hasIcon && icon <= 0) || (colors == 0 && !hasIcon) {
return "", 0, domain.ErrButtonInvalid
}
return out, icon, nil
}
func tgMarkupButtonStyle(btn domain.MarkupButton) (tg.KeyboardButtonStyle, bool) {
var out tg.KeyboardButtonStyle
switch btn.Style {
case domain.MarkupButtonStylePrimary:
out.SetBgPrimary(true)
case domain.MarkupButtonStyleDanger:
out.SetBgDanger(true)
case domain.MarkupButtonStyleSuccess:
out.SetBgSuccess(true)
}
if btn.IconCustomEmojiID > 0 {
out.SetIcon(btn.IconCustomEmojiID)
}
return out, btn.Style != "" || btn.IconCustomEmojiID > 0
}
// tgReplyMarkup 把存储的协议中立快照还原为对应 ReplyMarkup constructor。
func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
if m.IsZero() {
return nil
}
switch m.Kind() {
case domain.MessageReplyMarkupKeyboard:
rows := make([]tg.KeyboardButtonRow, 0, len(m.Keyboard))
for _, row := range m.Keyboard {
buttons := make([]tg.KeyboardButtonClass, 0, len(row))
for _, btn := range row {
buttons = append(buttons, tgReplyKeyboardButton(btn))
}
rows = append(rows, tg.KeyboardButtonRow{Buttons: buttons})
}
return &tg.ReplyKeyboardMarkup{
Resize: m.Resize,
SingleUse: m.SingleUse,
Selective: m.Selective,
Persistent: m.Persistent,
Rows: rows,
Placeholder: m.Placeholder,
}
case domain.MessageReplyMarkupHide:
return &tg.ReplyKeyboardHide{Selective: m.Selective}
case domain.MessageReplyMarkupForceReply:
return &tg.ReplyKeyboardForceReply{
SingleUse: m.SingleUse,
Selective: m.Selective,
Placeholder: m.Placeholder,
}
case domain.MessageReplyMarkupInline:
// Continue below.
default:
return nil
}
rows := make([]tg.KeyboardButtonRow, 0, len(m.Inline))
for _, row := range m.Inline {
buttons := make([]tg.KeyboardButtonClass, 0, len(row))
@ -114,12 +317,202 @@ func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
switch btn.Type {
case domain.MarkupButtonURL:
return &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL}
out := &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
case domain.MarkupButtonWebView:
out := &tg.KeyboardButtonWebView{Text: btn.Text, URL: btn.URL}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
case domain.MarkupButtonSwitchInline:
out := &tg.KeyboardButtonSwitchInline{Text: btn.Text, Query: btn.Query, SamePeer: btn.SamePeer}
if len(btn.PeerTypes) > 0 {
out.SetPeerTypes(tgPreparedInlinePeerTypes(btn.PeerTypes))
}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
case domain.MarkupButtonCopy:
out := &tg.KeyboardButtonCopy{Text: btn.Text, CopyText: btn.CopyText}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
default: // callback
out := &tg.KeyboardButtonCallback{Text: btn.Text, Data: btn.Data}
if btn.RequiresPassword {
out.SetRequiresPassword(true)
}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
}
}
func tgReplyKeyboardButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
var out tg.KeyboardButtonClass
switch btn.Type {
case domain.MarkupButtonRequestPhone:
out = &tg.KeyboardButtonRequestPhone{Text: btn.Text}
case domain.MarkupButtonRequestLocation:
out = &tg.KeyboardButtonRequestGeoLocation{Text: btn.Text}
case domain.MarkupButtonRequestPoll:
button := &tg.KeyboardButtonRequestPoll{Text: btn.Text}
if btn.PollType == "quiz" {
button.SetQuiz(true)
} else if btn.PollType == "regular" {
button.SetQuiz(false)
}
out = button
case domain.MarkupButtonRequestPeer:
out = &tg.KeyboardButtonRequestPeer{Text: btn.Text, ButtonID: btn.ButtonID, PeerType: tgRequestPeerTypeWithFilter(btn.RequestPeerType, btn.RequestPeerFilter), MaxQuantity: btn.MaxQuantity}
case domain.MarkupButtonSimpleWebView:
out = &tg.KeyboardButtonSimpleWebView{Text: btn.Text, URL: btn.URL}
default:
out = &tg.KeyboardButton{Text: btn.Text}
}
if style, ok := tgMarkupButtonStyle(btn); ok {
if setter, ok := out.(interface{ SetStyle(tg.KeyboardButtonStyle) }); ok {
setter.SetStyle(style)
}
}
return out
}
func domainRequestPeerFilter(peerType tg.RequestPeerTypeClass) (string, *domain.BotRequestPeerFilter) {
filter := &domain.BotRequestPeerFilter{}
switch v := peerType.(type) {
case *tg.RequestPeerTypeUser:
if value, ok := v.GetBot(); ok {
filter.UserIsBotSet, filter.UserIsBot = true, value
}
if value, ok := v.GetPremium(); ok {
filter.UserIsPremiumSet, filter.UserIsPremium = true, value
}
if !filter.UserIsBotSet && !filter.UserIsPremiumSet {
return "user", nil
}
return "user", filter
case *tg.RequestPeerTypeChat:
filter.ChatIsCreated, filter.BotIsMember = v.Creator, v.BotParticipant
if value, ok := v.GetHasUsername(); ok {
filter.ChatHasUsernameSet, filter.ChatHasUsername = true, value
}
if value, ok := v.GetForum(); ok {
filter.ChatIsForumSet, filter.ChatIsForum = true, value
}
if rights, ok := v.GetUserAdminRights(); ok {
mapped := domainBotRequestAdminRights(rights)
filter.UserAdminRights = &mapped
}
if rights, ok := v.GetBotAdminRights(); ok {
mapped := domainBotRequestAdminRights(rights)
filter.BotAdminRights = &mapped
}
if botRequestPeerFilterZero(filter) {
return "chat", nil
}
return "chat", filter
case *tg.RequestPeerTypeBroadcast:
filter.ChatIsCreated = v.Creator
if value, ok := v.GetHasUsername(); ok {
filter.ChatHasUsernameSet, filter.ChatHasUsername = true, value
}
if rights, ok := v.GetUserAdminRights(); ok {
mapped := domainBotRequestAdminRights(rights)
filter.UserAdminRights = &mapped
}
if rights, ok := v.GetBotAdminRights(); ok {
mapped := domainBotRequestAdminRights(rights)
filter.BotAdminRights = &mapped
}
if botRequestPeerFilterZero(filter) {
return "broadcast", nil
}
return "broadcast", filter
default:
return "", nil
}
}
func botRequestPeerFilterZero(filter *domain.BotRequestPeerFilter) bool {
return filter == nil || (!filter.UserIsBotSet && !filter.UserIsPremiumSet && !filter.ChatHasUsernameSet &&
!filter.ChatIsForumSet && !filter.ChatIsCreated && !filter.BotIsMember && filter.UserAdminRights == nil && filter.BotAdminRights == nil)
}
func tgRequestPeerTypeWithFilter(kind string, filter *domain.BotRequestPeerFilter) tg.RequestPeerTypeClass {
switch kind {
case "chat":
out := &tg.RequestPeerTypeChat{}
if filter != nil {
out.Creator, out.BotParticipant = filter.ChatIsCreated, filter.BotIsMember
if filter.ChatHasUsernameSet {
out.SetHasUsername(filter.ChatHasUsername)
}
if filter.ChatIsForumSet {
out.SetForum(filter.ChatIsForum)
}
if filter.UserAdminRights != nil {
out.SetUserAdminRights(tgBotRequestAdminRights(*filter.UserAdminRights))
}
if filter.BotAdminRights != nil {
out.SetBotAdminRights(tgBotRequestAdminRights(*filter.BotAdminRights))
}
}
return out
case "broadcast":
out := &tg.RequestPeerTypeBroadcast{}
if filter != nil {
out.Creator = filter.ChatIsCreated
if filter.ChatHasUsernameSet {
out.SetHasUsername(filter.ChatHasUsername)
}
if filter.UserAdminRights != nil {
out.SetUserAdminRights(tgBotRequestAdminRights(*filter.UserAdminRights))
}
if filter.BotAdminRights != nil {
out.SetBotAdminRights(tgBotRequestAdminRights(*filter.BotAdminRights))
}
}
return out
default:
out := &tg.RequestPeerTypeUser{}
if filter != nil {
if filter.UserIsBotSet {
out.SetBot(filter.UserIsBot)
}
if filter.UserIsPremiumSet {
out.SetPremium(filter.UserIsPremium)
}
}
return out
}
}
func domainBotRequestAdminRights(rights tg.ChatAdminRights) domain.BotRequestAdminRights {
return domain.BotRequestAdminRights{
Anonymous: rights.Anonymous, ManageChat: rights.Other, DeleteMessages: rights.DeleteMessages,
ManageVideoChats: rights.ManageCall, RestrictMembers: rights.BanUsers, PromoteMembers: rights.AddAdmins,
ChangeInfo: rights.ChangeInfo, InviteUsers: rights.InviteUsers, PostStories: rights.PostStories,
EditStories: rights.EditStories, DeleteStories: rights.DeleteStories, PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages, PinMessages: rights.PinMessages, ManageTopics: rights.ManageTopics,
ManageDirectMessages: rights.ManageDirectMessages,
}
}
func tgBotRequestAdminRights(rights domain.BotRequestAdminRights) tg.ChatAdminRights {
return tg.ChatAdminRights{
Anonymous: rights.Anonymous, Other: rights.ManageChat, DeleteMessages: rights.DeleteMessages,
ManageCall: rights.ManageVideoChats, BanUsers: rights.RestrictMembers, AddAdmins: rights.PromoteMembers,
ChangeInfo: rights.ChangeInfo, InviteUsers: rights.InviteUsers, PostStories: rights.PostStories,
EditStories: rights.EditStories, DeleteStories: rights.DeleteStories, PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages, PinMessages: rights.PinMessages, ManageTopics: rights.ManageTopics,
ManageDirectMessages: rights.ManageDirectMessages,
}
}