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
|
|
@ -1,12 +1,14 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -18,8 +20,13 @@ const botCallbackTimeout = 25 * time.Second
|
|||
func botResponseTimeoutErr() error { return tgerr.New(502, "BOT_RESPONSE_TIMEOUT") }
|
||||
func dataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") }
|
||||
|
||||
// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把 updateBotCallbackQuery
|
||||
// 推给 bot,挂起等待 bot 的 setBotCallbackAnswer,或超时回 BOT_RESPONSE_TIMEOUT。
|
||||
type privateMessageByUIDService interface {
|
||||
GetMessageByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error)
|
||||
}
|
||||
|
||||
// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把同一 callback query
|
||||
// 同时投递到在线 MTProto bot session 与 Bot API update_id 队列,挂起等待 bot 的
|
||||
// setBotCallbackAnswer/answerCallbackQuery,或超时回 BOT_RESPONSE_TIMEOUT。
|
||||
func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.MessagesGetBotCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -32,11 +39,6 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// callback 按钮只存在于 bot 的私聊消息。peer 必须是 bot 用户。
|
||||
if peer.Type != domain.PeerTypeUser || !r.userIsBot(ctx, peer.ID) {
|
||||
return nil, dataInvalidErr()
|
||||
}
|
||||
botUserID := peer.ID
|
||||
// game 按钮(getBotCallbackAnswer.game)P3 不支持:返回空答案(客户端不弹任何东西),
|
||||
// 不挂起、不推送(避免给 bot 投递无法处理的 game query)。
|
||||
if req.Game {
|
||||
|
|
@ -46,32 +48,64 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
|
|||
if !hasData {
|
||||
return nil, dataInvalidErr()
|
||||
}
|
||||
if len(data) > domain.MaxCallbackDataLen {
|
||||
return nil, dataInvalidErr()
|
||||
}
|
||||
// 校验目标消息存在于请求者自己的盒、且对端正是该 bot。
|
||||
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
msg, ok, err := r.lookupOwnerMessage(ctx, userID, req.MsgID)
|
||||
callback, err := r.resolveBotCallbackQuery(ctx, userID, peer, req.MsgID, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
botUserID := callback.BotUserID
|
||||
|
||||
queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), botUserID, userID, botCallbackTimeout)
|
||||
if err != nil {
|
||||
r.log.Warn("register shared bot callback query", zap.Int64("bot_user_id", botUserID), zap.Error(err))
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !ok || msg.Peer != peer {
|
||||
return nil, messageIDInvalidErr()
|
||||
defer r.callbacks.deregisterContext(context.Background(), botUserID, queryID)
|
||||
callback.ID = queryID
|
||||
|
||||
// Bot API callback_query shares the dedicated durable update_id queue with message and
|
||||
// edited_message. The callback answer waiter itself remains ephemeral/process-local.
|
||||
if r.deps.BotAPIUpdates != nil {
|
||||
if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: botUserID,
|
||||
Kind: domain.BotAPIUpdateCallbackQuery,
|
||||
Peer: callback.Peer,
|
||||
MessageID: callback.MessageID,
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
Callback: &callback,
|
||||
}); err != nil {
|
||||
r.log.Warn("enqueue bot api callback query",
|
||||
zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err))
|
||||
return nil, internalErr()
|
||||
} else if created {
|
||||
r.notifyBotAPIUpdate(botUserID)
|
||||
}
|
||||
}
|
||||
|
||||
queryID, pending := r.callbacks.register(botUserID, userID)
|
||||
defer r.callbacks.deregister(queryID)
|
||||
|
||||
// updateBotCallbackQuery 是 ephemeral(无 pts/qts,不进 getDifference):仅在线推给
|
||||
// bot;bot 离线则投递 0,但仍走超时窗口(I5,给 bot 上线追答机会)。
|
||||
// MsgID 透传请求者侧的 box id(P3 不做 bot 侧 box id 翻译——bot 侧消息编辑后移,记 todo)。
|
||||
update := &tg.UpdateBotCallbackQuery{
|
||||
QueryID: queryID,
|
||||
UserID: userID,
|
||||
Peer: &tg.PeerUser{UserID: userID},
|
||||
MsgID: req.MsgID,
|
||||
ChatInstance: chatInstanceFor(botUserID, userID),
|
||||
// updateBotCallbackQuery 是 ephemeral(无 pts/qts,不进 getDifference);私聊 MessageID
|
||||
// 已翻译为 bot 视角 box id,channel 使用共享 message id。
|
||||
var update tg.UpdateClass
|
||||
if callback.InlineMessage != nil {
|
||||
inline := &tg.UpdateInlineBotCallbackQuery{
|
||||
QueryID: queryID, UserID: userID,
|
||||
MsgID: tgInputBotInlineMessageID(*callback.InlineMessage), ChatInstance: callback.ChatInstance,
|
||||
}
|
||||
inline.SetData(data)
|
||||
update = inline
|
||||
} else {
|
||||
direct := &tg.UpdateBotCallbackQuery{
|
||||
QueryID: queryID, UserID: userID, Peer: tgPeer(callback.Peer),
|
||||
MsgID: callback.MessageID, ChatInstance: callback.ChatInstance,
|
||||
}
|
||||
direct.SetData(data)
|
||||
update = direct
|
||||
}
|
||||
update.SetData(data)
|
||||
r.pushUserMessage(ctx, botUserID, "push bot callback query", &tg.Updates{
|
||||
Updates: []tg.UpdateClass{update},
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
|
|
@ -79,14 +113,149 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
|
|||
|
||||
waitCtx, cancel := context.WithTimeout(ctx, botCallbackTimeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case ans := <-pending.ch:
|
||||
return tgBotCallbackAnswer(ans), nil
|
||||
case <-waitCtx.Done():
|
||||
return nil, botResponseTimeoutErr()
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case ans := <-pending.ch:
|
||||
return tgBotCallbackAnswer(ans), nil
|
||||
case <-ticker.C:
|
||||
ans, found, err := r.callbacks.sharedAnswer(waitCtx, botUserID, queryID)
|
||||
if err != nil {
|
||||
r.log.Warn("read shared bot callback answer", zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
return tgBotCallbackAnswer(ans), nil
|
||||
}
|
||||
case <-waitCtx.Done():
|
||||
return nil, botResponseTimeoutErr()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveBotCallbackQuery validates the clicked message and resolves the bot-visible message
|
||||
// identity. Inline-mode via_bot messages require updateInlineBotCallbackQuery + signed inline
|
||||
// ids and therefore remain an explicit blocked path instead of being misrouted here.
|
||||
func (r *Router) resolveBotCallbackQuery(ctx context.Context, userID int64, peer domain.Peer, msgID int, data []byte) (domain.BotCallbackQuery, error) {
|
||||
if peer.Type == domain.PeerTypeUser {
|
||||
msg, found, err := r.lookupOwnerMessage(ctx, userID, msgID)
|
||||
if err != nil {
|
||||
return domain.BotCallbackQuery{}, internalErr()
|
||||
}
|
||||
if !found || msg.Peer != peer || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
if msg.ViaBotID != 0 {
|
||||
if !r.userIsBot(ctx, msg.ViaBotID) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
inlineID, ok := r.inputInlineMessageIDForPrivateMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64)
|
||||
if !ok {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
return domain.BotCallbackQuery{
|
||||
BotUserID: msg.ViaBotID, UserID: userID,
|
||||
ChatInstance: chatInstanceFor(msg.ViaBotID, userID), Data: append([]byte(nil), data...),
|
||||
InlineMessage: domainInlineMessageID(inlineID),
|
||||
}, nil
|
||||
}
|
||||
if msg.From.Type != domain.PeerTypeUser || msg.From.ID == 0 || !r.userIsBot(ctx, msg.From.ID) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
provider, ok := r.deps.Messages.(privateMessageByUIDService)
|
||||
if !ok || msg.UID == 0 {
|
||||
return domain.BotCallbackQuery{}, internalErr()
|
||||
}
|
||||
botMessage, found, err := provider.GetMessageByUID(ctx, msg.From.ID, msg.UID)
|
||||
if err != nil {
|
||||
return domain.BotCallbackQuery{}, internalErr()
|
||||
}
|
||||
if !found || botMessage.ID <= 0 || botMessage.OwnerUserID != msg.From.ID ||
|
||||
botMessage.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
return domain.BotCallbackQuery{
|
||||
BotUserID: msg.From.ID,
|
||||
UserID: userID,
|
||||
Peer: botMessage.Peer,
|
||||
MessageID: botMessage.ID,
|
||||
ChatInstance: chatInstanceFor(msg.From.ID, userID),
|
||||
Data: append([]byte(nil), data...),
|
||||
}, nil
|
||||
}
|
||||
if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil {
|
||||
return domain.BotCallbackQuery{}, peerIDInvalidErr()
|
||||
}
|
||||
history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{msgID})
|
||||
if err != nil {
|
||||
return domain.BotCallbackQuery{}, channelInvalidErr(err)
|
||||
}
|
||||
if len(history.Messages) != 1 {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
msg := history.Messages[0]
|
||||
if msg.ID != msgID || msg.Deleted || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
if msg.ViaBotID != 0 {
|
||||
if !r.userIsBot(ctx, msg.ViaBotID) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
inlineID, ok := r.inputInlineMessageIDForChannelMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64)
|
||||
if !ok {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
return domain.BotCallbackQuery{
|
||||
BotUserID: msg.ViaBotID, UserID: userID,
|
||||
ChatInstance: chatInstanceForPeer(msg.ViaBotID, peer), Data: append([]byte(nil), data...),
|
||||
InlineMessage: domainInlineMessageID(inlineID),
|
||||
}, nil
|
||||
}
|
||||
if msg.SenderUserID == 0 || !r.userIsBot(ctx, msg.SenderUserID) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
return domain.BotCallbackQuery{
|
||||
BotUserID: msg.SenderUserID,
|
||||
UserID: userID,
|
||||
Peer: peer,
|
||||
MessageID: msg.ID,
|
||||
ChatInstance: chatInstanceForPeer(msg.SenderUserID, peer),
|
||||
Data: append([]byte(nil), data...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func domainInlineMessageID(id *tg.InputBotInlineMessageID64) *domain.BotInlineMessageID {
|
||||
if id == nil {
|
||||
return nil
|
||||
}
|
||||
return &domain.BotInlineMessageID{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash}
|
||||
}
|
||||
|
||||
func tgInputBotInlineMessageID(id domain.BotInlineMessageID) tg.InputBotInlineMessageIDClass {
|
||||
return &tg.InputBotInlineMessageID64{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash}
|
||||
}
|
||||
|
||||
func replyMarkupContainsCallbackData(markup *domain.MessageReplyMarkup, data []byte) bool {
|
||||
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
|
||||
return false
|
||||
}
|
||||
for _, row := range markup.Inline {
|
||||
for _, button := range row {
|
||||
if button.Type == domain.MarkupButtonCallback && bytes.Equal(button.Data, data) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// onMessagesSetBotCallbackAnswer 是 bot 对一次 callback query 的应答:解挂等待中的
|
||||
// getBotCallbackAnswer。仅属主 bot 可解挂(callerBotID==pending.botUserID,I6)。
|
||||
func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.MessagesSetBotCallbackAnswerRequest) (bool, error) {
|
||||
|
|
@ -106,7 +275,9 @@ func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.Mes
|
|||
}
|
||||
// resolve 返回是否投递成功;未注册/超时/非属主一律 false。对 bot 而言答案是否
|
||||
// 被等待者接收无关紧要(官方恒返回 true),但非属主必须拒绝投递(防钓鱼弹窗)。
|
||||
r.callbacks.resolve(botID, req.QueryID, ans)
|
||||
if _, err := r.callbacks.resolveContext(ctx, botID, req.QueryID, ans); err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue