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
|
|
@ -8,7 +8,10 @@ import (
|
|||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
var botAPIAuthKeyID = [8]byte{'B', 'O', 'T', 'A', 'P', 'I', 0, 1}
|
||||
|
|
@ -61,6 +64,114 @@ func (r *Router) BotAPIUpdates(ctx context.Context, botID int64, offset int64) (
|
|||
return r.enrichUpdateEvents(ctx, botID, diff.Events), nil
|
||||
}
|
||||
|
||||
func (r *Router) BotAPISetAllowedUpdates(ctx context.Context, botID int64, allowed []domain.BotAPIUpdateKind) error {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.deps.BotAPIUpdates.SetBotAPIAllowedUpdates(ctx, botID, allowed)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIDropPendingUpdates(ctx context.Context, botID int64) error {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.deps.BotAPIUpdates.DropPendingBotAPIUpdates(ctx, botID)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIPendingUpdateCount(ctx context.Context, botID int64) (int, error) {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return r.deps.BotAPIUpdates.PendingBotAPIUpdateCount(ctx, botID)
|
||||
}
|
||||
|
||||
func (r *Router) AcquireBotAPIPollLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore)
|
||||
if !ok || botID <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
return leases.AcquireBotAPIPollLease(ctx, botID, owner, ttl)
|
||||
}
|
||||
|
||||
func (r *Router) ReleaseBotAPIPollLease(ctx context.Context, botID int64, owner string) error {
|
||||
leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore)
|
||||
if !ok || botID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return leases.ReleaseBotAPIPollLease(ctx, botID, owner)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPISetWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return errors.New("WEBHOOK_UNSUPPORTED")
|
||||
}
|
||||
return webhooks.SetBotAPIWebhook(ctx, config, dropPending)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIDeleteWebhook(ctx context.Context, botID int64, dropPending bool) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return errors.New("WEBHOOK_UNSUPPORTED")
|
||||
}
|
||||
return webhooks.DeleteBotAPIWebhook(ctx, botID, dropPending)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIWebhook(ctx context.Context, botID int64) (domain.BotAPIWebhook, bool, error) {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return domain.BotAPIWebhook{}, false, nil
|
||||
}
|
||||
return webhooks.BotAPIWebhook(ctx, botID)
|
||||
}
|
||||
|
||||
func (r *Router) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return webhooks.ListDueBotAPIWebhooks(ctx, limit)
|
||||
}
|
||||
|
||||
func (r *Router) AcquireBotAPIWebhookLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
return webhooks.AcquireBotAPIWebhookLease(ctx, botID, owner, ttl)
|
||||
}
|
||||
|
||||
func (r *Router) ReleaseBotAPIWebhookLease(ctx context.Context, botID int64, owner string) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return webhooks.ReleaseBotAPIWebhookLease(ctx, botID, owner)
|
||||
}
|
||||
|
||||
func (r *Router) RecordBotAPIWebhookFailure(ctx context.Context, botID int64, owner string, nextAttempt time.Time, message string) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return webhooks.RecordBotAPIWebhookFailure(ctx, botID, owner, nextAttempt, message)
|
||||
}
|
||||
|
||||
func (r *Router) RecordBotAPIWebhookSuccess(ctx context.Context, botID int64, owner string, nextAttempt time.Time) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return webhooks.RecordBotAPIWebhookSuccess(ctx, botID, owner, nextAttempt)
|
||||
}
|
||||
|
||||
func (r *Router) ConfirmBotAPIWebhookDelivery(ctx context.Context, botID, updateID int64) error {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || botID <= 0 || updateID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, updateID)
|
||||
}
|
||||
|
||||
// BotAPISendMessage sends a text message as a bot through the normal private
|
||||
// or channel message state machine. Positive chat_id is a user private chat;
|
||||
// -1000000000000-channel_id is a supergroup/channel chat.
|
||||
|
|
@ -72,6 +183,12 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex
|
|||
if !ok {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return domain.Message{}, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if text == "" {
|
||||
return domain.Message{}, errors.New("MESSAGE_EMPTY")
|
||||
}
|
||||
|
|
@ -122,6 +239,12 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
|
|||
if !ok {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return domain.Message{}, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if utf8.RuneCountInString(caption) > domain.MaxMessageTextLength {
|
||||
return domain.Message{}, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
|
|
@ -400,6 +523,37 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
|
|||
return self.Message, nil
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error) {
|
||||
if r == nil || botID == 0 || !r.userIsBot(ctx, botID) {
|
||||
return false, errors.New("BOT_INVALID")
|
||||
}
|
||||
if text == "" {
|
||||
return false, errors.New("MESSAGE_EMPTY")
|
||||
}
|
||||
if utf8.RuneCountInString(text) > domain.MaxMessageTextLength {
|
||||
return false, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
req := &tg.MessagesEditInlineBotMessageRequest{
|
||||
ID: tgInputBotInlineMessageID(inlineMessageID),
|
||||
NoWebpage: disableWebPagePreview,
|
||||
}
|
||||
req.SetMessage(text)
|
||||
if len(entities) > 0 {
|
||||
req.SetEntities(tgMessageEntities(entities))
|
||||
}
|
||||
if setReplyMarkup {
|
||||
wire := tgReplyMarkup(replyMarkup)
|
||||
if wire == nil {
|
||||
wire = &tg.ReplyInlineMarkup{}
|
||||
}
|
||||
req.SetReplyMarkup(wire)
|
||||
}
|
||||
return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req)
|
||||
}
|
||||
|
||||
// BotAPIDeleteMessage deletes a bot-owned private message with revoke=true so
|
||||
// the target user's MTProto clients observe the normal delete update.
|
||||
func (r *Router) BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error) {
|
||||
|
|
@ -440,12 +594,18 @@ func (r *Router) BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, cal
|
|||
if cacheTime < 0 {
|
||||
cacheTime = 0
|
||||
}
|
||||
r.callbacks.resolve(botID, queryID, domain.BotCallbackAnswer{
|
||||
resolved, resolveErr := r.callbacks.resolveContext(ctx, botID, queryID, domain.BotCallbackAnswer{
|
||||
Alert: showAlert,
|
||||
Message: text,
|
||||
URL: url,
|
||||
CacheTime: cacheTime,
|
||||
})
|
||||
if resolveErr != nil {
|
||||
return false, resolveErr
|
||||
}
|
||||
if !resolved {
|
||||
return false, errors.New("QUERY_ID_INVALID")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appbots "telesrv/internal/app/bots"
|
||||
|
|
@ -17,6 +20,170 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestBotAPICallbackQueryPrivatePollingAndAnswer(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
data := []byte("private-confirm")
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data,
|
||||
}}}}
|
||||
sent, err := fixture.messages.SendPrivateText(fixture.ctx, fixture.bot.ID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: fixture.bot.ID, RecipientUserID: fixture.owner.ID,
|
||||
RandomID: 90001, Message: "tap private", Date: 200, ReplyMarkup: markup,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
if _, err := fixture.router.resolveBotCallbackQuery(
|
||||
fixture.ctx,
|
||||
fixture.owner.ID,
|
||||
domain.Peer{Type: domain.PeerTypeUser, ID: fixture.bot.ID},
|
||||
sent.RecipientMessage.ID,
|
||||
[]byte("forged-callback-data"),
|
||||
); !tgerr.Is(err, "DATA_INVALID") {
|
||||
t.Fatalf("forged callback data err = %v, want DATA_INVALID", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second)
|
||||
defer cancel()
|
||||
answerCh := make(chan struct {
|
||||
answer *tg.MessagesBotCallbackAnswer
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
req := &tg.MessagesGetBotCallbackAnswerRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: fixture.bot.ID, AccessHash: fixture.bot.AccessHash},
|
||||
MsgID: sent.RecipientMessage.ID,
|
||||
}
|
||||
req.SetData(data)
|
||||
answer, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req)
|
||||
answerCh <- struct {
|
||||
answer *tg.MessagesBotCallbackAnswer
|
||||
err error
|
||||
}{answer: answer, err: err}
|
||||
}()
|
||||
|
||||
event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID)
|
||||
if event.Message.ID != sent.SenderMessage.ID || event.Message.OwnerUserID != fixture.bot.ID || !event.Message.Out {
|
||||
t.Fatalf("callback message = %+v, want bot-side box id %d", event.Message, sent.SenderMessage.ID)
|
||||
}
|
||||
callback := event.BotCallbackQuery
|
||||
if callback == nil || callback.UserID != fixture.owner.ID || callback.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}) ||
|
||||
callback.MessageID != sent.SenderMessage.ID || string(callback.Data) != string(data) {
|
||||
t.Fatalf("callback = %+v", callback)
|
||||
}
|
||||
if ok, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "accepted", "", false, 0); err != nil || !ok {
|
||||
t.Fatalf("BotAPIAnswerCallbackQuery = %v, %v", ok, err)
|
||||
}
|
||||
select {
|
||||
case result := <-answerCh:
|
||||
if result.err != nil || result.answer == nil || result.answer.Message != "accepted" {
|
||||
t.Fatalf("callback answer = %+v err=%v", result.answer, result.err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("callback answer did not unblock requester")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPICallbackQueryRejectsExpiredOrUnknownAnswer(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
if ok, err := fixture.router.BotAPIAnswerCallbackQuery(fixture.ctx, fixture.bot.ID, "999", "late", "", false, 0); err == nil || ok || !strings.Contains(err.Error(), "QUERY_ID_INVALID") {
|
||||
t.Fatalf("unknown answer = ok=%v err=%v", ok, err)
|
||||
}
|
||||
item := domain.BotAPIUpdate{
|
||||
ID: 1, BotUserID: fixture.bot.ID, Kind: domain.BotAPIUpdateCallbackQuery,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1,
|
||||
Date: 100,
|
||||
Callback: &domain.BotCallbackQuery{
|
||||
ID: 2, BotUserID: fixture.bot.ID, UserID: fixture.owner.ID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1, ChatInstance: 3,
|
||||
},
|
||||
}
|
||||
if _, ok := botAPIQueuedUpdateKind(fixture.bot.ID, item, time.Unix(100, 0).Add(botCallbackTimeout)); ok {
|
||||
t.Fatal("callback at answer deadline remained deliverable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIInlineCallbackDoesNotHydrateNonexistentChatMessage(t *testing.T) {
|
||||
now := time.Unix(200, 0)
|
||||
inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 17, AccessHash: 9988}
|
||||
item := domain.BotAPIUpdate{
|
||||
ID: 55, BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(now.Unix()),
|
||||
Callback: &domain.BotCallbackQuery{
|
||||
ID: 77, BotUserID: 1001, UserID: 2001, ChatInstance: 99,
|
||||
Data: []byte("inline"), InlineMessage: inline,
|
||||
},
|
||||
}
|
||||
event, ok := botAPIQueuedUpdateEventFromMessages(1001, item, nil, nil, now)
|
||||
if !ok || event.Type != domain.UpdateEventBotCallbackQuery || event.Message.ID != 0 || event.Peer != (domain.Peer{}) ||
|
||||
event.BotCallbackQuery == nil || event.BotCallbackQuery.InlineMessage == nil || *event.BotCallbackQuery.InlineMessage != *inline {
|
||||
t.Fatalf("inline callback event=%#v ok=%v", event, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPICallbackQuerySupergroupPollingAndAnswer(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
data := []byte("group-confirm")
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data,
|
||||
}}}}
|
||||
sent, err := fixture.channels.SendMessage(fixture.ctx, fixture.bot.ID, domain.SendChannelMessageRequest{
|
||||
UserID: fixture.bot.ID, ChannelID: fixture.channel.ID, RandomID: 90002,
|
||||
Message: "tap group", Date: 201, ReplyMarkup: markup, SkipRecipientLookup: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second)
|
||||
defer cancel()
|
||||
answerCh := make(chan error, 1)
|
||||
go func() {
|
||||
req := &tg.MessagesGetBotCallbackAnswerRequest{
|
||||
Peer: &tg.InputPeerChannel{ChannelID: fixture.channel.ID, AccessHash: fixture.channel.AccessHash},
|
||||
MsgID: sent.Message.ID,
|
||||
}
|
||||
req.SetData(data)
|
||||
_, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req)
|
||||
answerCh <- err
|
||||
}()
|
||||
|
||||
event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID)
|
||||
callback := event.BotCallbackQuery
|
||||
if callback == nil || callback.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: fixture.channel.ID}) ||
|
||||
callback.MessageID != sent.Message.ID || event.Message.ID != sent.Message.ID || !event.Message.Out {
|
||||
t.Fatalf("group callback event = %+v", event)
|
||||
}
|
||||
if _, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "", "", false, 0); err != nil {
|
||||
t.Fatalf("BotAPIAnswerCallbackQuery: %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-answerCh:
|
||||
if err != nil {
|
||||
t.Fatalf("group callback answer: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("group callback answer did not unblock requester")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForBotAPICallbackEvent(t *testing.T, ctx context.Context, router *Router, botID int64) domain.UpdateEvent {
|
||||
t.Helper()
|
||||
for {
|
||||
events, err := router.BotAPIUpdates(ctx, botID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIUpdates: %v", err)
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Type == domain.UpdateEventBotCallbackQuery {
|
||||
return event
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("callback query did not reach Bot API queue")
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
@ -49,7 +216,12 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
|
|||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
chatID := -botAPIChannelChatIDBase - created.Channel.ID
|
||||
msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, nil, false, false, 0)
|
||||
replyKeyboard := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupKeyboard,
|
||||
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}},
|
||||
Resize: true,
|
||||
}
|
||||
msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, replyKeyboard, false, false, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPISendMessage: %v", err)
|
||||
}
|
||||
|
|
@ -67,7 +239,9 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("GetHistory: %v", err)
|
||||
}
|
||||
if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body {
|
||||
if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body ||
|
||||
history.Messages[0].ReplyMarkup == nil || history.Messages[0].ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard ||
|
||||
history.Messages[0].ReplyMarkup.Keyboard[0][0].Text != "Help" {
|
||||
t.Fatalf("history messages = %+v, want bot channel message", history.Messages)
|
||||
}
|
||||
if pushed := sessions.pushedUserIDs(); !fanoutHasID(pushed, owner.ID) {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,16 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const botAPIGetUpdatesLimit = 100
|
||||
const (
|
||||
botAPIGetUpdatesLimit = 100
|
||||
botAPIMaxNegativeOffset = 10000
|
||||
)
|
||||
|
||||
type botAPIChannelBotMemberProvider interface {
|
||||
ActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error)
|
||||
|
|
@ -17,24 +22,43 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in
|
|||
return nil, nil
|
||||
}
|
||||
fromID := int64(1)
|
||||
if offset > 0 {
|
||||
var items []domain.BotAPIUpdate
|
||||
if offset < 0 {
|
||||
if offset < -botAPIMaxNegativeOffset {
|
||||
return nil, errors.New("OFFSET_INVALID")
|
||||
}
|
||||
var err error
|
||||
items, err = r.deps.BotAPIUpdates.ListTailBotAPIUpdates(ctx, botID, int(-offset), botAPIGetUpdatesLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) > 0 && items[0].ID > 1 {
|
||||
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, items[0].ID-1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else if offset > 0 {
|
||||
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, offset-1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fromID = offset
|
||||
} else if confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID); err != nil {
|
||||
return nil, err
|
||||
} else if found {
|
||||
fromID = confirmed + 1
|
||||
}
|
||||
items, err := r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if offset >= 0 {
|
||||
confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
fromID = confirmed + 1
|
||||
}
|
||||
items, err = r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items)
|
||||
events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items, r.clock.Now())
|
||||
if leadingSkipped > 0 {
|
||||
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, leadingSkipped); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -46,13 +70,16 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in
|
|||
return r.enrichUpdateEvents(ctx, botID, events), nil
|
||||
}
|
||||
|
||||
func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate) ([]domain.UpdateEvent, int64) {
|
||||
func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate, now time.Time) ([]domain.UpdateEvent, int64) {
|
||||
privateIDs := make([]int, 0)
|
||||
privateSeen := make(map[int]struct{})
|
||||
channelIDs := make(map[int64][]int)
|
||||
channelSeen := make(map[int64]map[int]struct{})
|
||||
for _, item := range items {
|
||||
if _, ok := botAPIQueuedUpdateKind(botID, item); !ok {
|
||||
if _, ok := botAPIQueuedUpdateKind(botID, item, now); !ok {
|
||||
continue
|
||||
}
|
||||
if item.Callback != nil && item.Callback.InlineMessage != nil {
|
||||
continue
|
||||
}
|
||||
switch item.Peer.Type {
|
||||
|
|
@ -79,7 +106,7 @@ func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, item
|
|||
events := make([]domain.UpdateEvent, 0, len(items))
|
||||
leadingSkipped := int64(0)
|
||||
for _, item := range items {
|
||||
event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages)
|
||||
event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages, now)
|
||||
if !ok {
|
||||
if len(events) == 0 {
|
||||
leadingSkipped = item.ID
|
||||
|
|
@ -101,7 +128,7 @@ func (r *Router) botAPIQueuedPrivateMessages(ctx context.Context, botID int64, i
|
|||
}
|
||||
out := make(map[int]domain.Message, len(list.Messages))
|
||||
for _, msg := range list.Messages {
|
||||
if msg.ID <= 0 || msg.Out || !botAPIMessageProjectable(msg) {
|
||||
if msg.ID <= 0 || msg.OwnerUserID != botID {
|
||||
continue
|
||||
}
|
||||
out[msg.ID] = msg
|
||||
|
|
@ -127,10 +154,6 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i
|
|||
if msg.ID <= 0 || msg.Deleted || msg.Action != nil {
|
||||
continue
|
||||
}
|
||||
projected := botAPIMessageFromChannel(botID, msg)
|
||||
if projected.Out || !botAPIMessageProjectable(projected) {
|
||||
continue
|
||||
}
|
||||
byID[msg.ID] = msg
|
||||
}
|
||||
if len(byID) > 0 {
|
||||
|
|
@ -140,14 +163,37 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i
|
|||
return out
|
||||
}
|
||||
|
||||
func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.UpdateEventType, bool) {
|
||||
if item.ID <= 0 || item.BotUserID != botID || item.MessageID <= 0 {
|
||||
func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time) (domain.UpdateEventType, bool) {
|
||||
if item.ID <= 0 || item.BotUserID != botID {
|
||||
return "", false
|
||||
}
|
||||
eventType, ok := botAPIUpdateEventType(item.Kind)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if item.Kind == domain.BotAPIUpdateCallbackQuery {
|
||||
if item.Date <= 0 || !now.Before(time.Unix(int64(item.Date), 0).Add(botCallbackTimeout)) {
|
||||
return "", false
|
||||
}
|
||||
cb := item.Callback
|
||||
if cb == nil || cb.ID == 0 || cb.BotUserID != botID || cb.UserID <= 0 ||
|
||||
cb.ChatInstance == 0 || len(cb.Data) > domain.MaxCallbackDataLen {
|
||||
return "", false
|
||||
}
|
||||
if cb.InlineMessage != nil {
|
||||
inline := cb.InlineMessage
|
||||
if item.MessageID != 0 || item.Peer != (domain.Peer{}) || cb.MessageID != 0 || cb.Peer != (domain.Peer{}) ||
|
||||
inline.DCID <= 0 || inline.OwnerID == 0 || inline.ID <= 0 || inline.AccessHash == 0 {
|
||||
return "", false
|
||||
}
|
||||
return eventType, true
|
||||
}
|
||||
if item.MessageID <= 0 || cb.Peer != item.Peer || cb.MessageID != item.MessageID {
|
||||
return "", false
|
||||
}
|
||||
} else if item.MessageID <= 0 {
|
||||
return "", false
|
||||
}
|
||||
switch item.Peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
if item.Peer.ID <= 0 {
|
||||
|
|
@ -159,17 +205,48 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.Updat
|
|||
return eventType, true
|
||||
}
|
||||
|
||||
func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage) (domain.UpdateEvent, bool) {
|
||||
eventType, ok := botAPIQueuedUpdateKind(botID, item)
|
||||
func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage, now time.Time) (domain.UpdateEvent, bool) {
|
||||
eventType, ok := botAPIQueuedUpdateKind(botID, item, now)
|
||||
if !ok {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
if eventType == domain.UpdateEventBotCallbackQuery && item.Callback.InlineMessage != nil {
|
||||
callback := *item.Callback
|
||||
callback.Data = append([]byte(nil), item.Callback.Data...)
|
||||
inline := *item.Callback.InlineMessage
|
||||
callback.InlineMessage = &inline
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
Date: item.Date,
|
||||
BotCallbackQuery: &callback,
|
||||
}, true
|
||||
}
|
||||
switch item.Peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
msg, found := privateMessages[item.MessageID]
|
||||
if !found {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
if eventType == domain.UpdateEventBotCallbackQuery {
|
||||
callback := *item.Callback
|
||||
callback.Data = append([]byte(nil), item.Callback.Data...)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
Date: item.Date,
|
||||
Peer: item.Peer,
|
||||
Message: msg,
|
||||
BotCallbackQuery: &callback,
|
||||
}, true
|
||||
}
|
||||
if msg.Out || !botAPIMessageProjectable(msg) {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
msg.Pts = int(item.ID)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
|
|
@ -186,6 +263,23 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
|
|||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
projected := botAPIMessageFromChannel(botID, msg)
|
||||
if eventType == domain.UpdateEventBotCallbackQuery {
|
||||
callback := *item.Callback
|
||||
callback.Data = append([]byte(nil), item.Callback.Data...)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
Date: item.Date,
|
||||
Peer: item.Peer,
|
||||
Message: projected,
|
||||
BotCallbackQuery: &callback,
|
||||
}, true
|
||||
}
|
||||
if projected.Out || !botAPIMessageProjectable(projected) {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
projected.Pts = int(item.ID)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
|
|
@ -207,6 +301,8 @@ func botAPIUpdateEventType(kind domain.BotAPIUpdateKind) (domain.UpdateEventType
|
|||
return domain.UpdateEventNewMessage, true
|
||||
case domain.BotAPIUpdateEditedMessage:
|
||||
return domain.UpdateEventEditMessage, true
|
||||
case domain.BotAPIUpdateCallbackQuery:
|
||||
return domain.UpdateEventBotCallbackQuery, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
|
|
@ -410,7 +506,56 @@ func botAPIMessageMediaProjectable(media *domain.MessageMedia) bool {
|
|||
return media.Photo != nil
|
||||
case domain.MessageMediaKindDocument:
|
||||
return media.Document != nil
|
||||
case domain.MessageMediaKindContact:
|
||||
return media.Contact != nil
|
||||
case domain.MessageMediaKindGeo:
|
||||
return media.Geo != nil
|
||||
case domain.MessageMediaKindVenue:
|
||||
return media.Venue != nil
|
||||
case domain.MessageMediaKindPoll:
|
||||
return media.Poll != nil
|
||||
case domain.MessageMediaKindGeoLive:
|
||||
return media.GeoLive != nil
|
||||
case domain.MessageMediaKindService:
|
||||
if media.ServiceAction == nil {
|
||||
return false
|
||||
}
|
||||
switch media.ServiceAction.Kind {
|
||||
case domain.MessageServiceActionWebViewDataSent:
|
||||
return media.ServiceAction.WebViewData != nil
|
||||
case domain.MessageServiceActionRequestedPeer:
|
||||
return botAPIRequestedPeerProjectable(media.ServiceAction.RequestedPeer)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIRequestedPeerProjectable(action *domain.MessageRequestedPeerAction) bool {
|
||||
if action == nil || action.ButtonID == 0 || len(action.Peers) == 0 || len(action.Peers) > domain.MaxBotRequestedPeerQuantity {
|
||||
return false
|
||||
}
|
||||
details := make(map[domain.Peer]struct{}, len(action.Details))
|
||||
for _, detail := range action.Details {
|
||||
if detail.Peer.ID == 0 || (detail.Peer.Type != domain.PeerTypeUser && detail.Peer.Type != domain.PeerTypeChannel) {
|
||||
return false
|
||||
}
|
||||
details[detail.Peer] = struct{}{}
|
||||
}
|
||||
requiresDetails := action.NameRequested || action.UsernameRequested || action.PhotoRequested
|
||||
allUsers := true
|
||||
for _, peer := range action.Peers {
|
||||
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
|
||||
return false
|
||||
}
|
||||
if requiresDetails {
|
||||
if _, ok := details[peer]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
allUsers = allUsers && peer.Type == domain.PeerTypeUser
|
||||
}
|
||||
return allUsers || (len(action.Peers) == 1 && action.Peers[0].Type == domain.PeerTypeChannel)
|
||||
}
|
||||
|
|
|
|||
70
internal/rpc/botapi_update_queue_projection_test.go
Normal file
70
internal/rpc/botapi_update_queue_projection_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestBotAPIMessageMediaProjectableReplyKeyboardResponses(t *testing.T) {
|
||||
validRequestedUsers := &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeUser, ID: 1002}},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
media *domain.MessageMedia
|
||||
want bool
|
||||
}{
|
||||
{"contact", &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{}}, true},
|
||||
{"geo", &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &domain.MessageGeoPoint{}}, true},
|
||||
{"venue", &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{}}, true},
|
||||
{"poll", &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: &domain.MessagePoll{}}, true},
|
||||
{"live geo", &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: &domain.MessageGeoLive{}}, true},
|
||||
{"web app", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionWebViewDataSent, WebViewData: &domain.MessageWebViewDataAction{},
|
||||
}}, true},
|
||||
{"requested users", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: validRequestedUsers,
|
||||
}}, true},
|
||||
{"requested disclosure without snapshot", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}}, NameRequested: true,
|
||||
},
|
||||
}}, false},
|
||||
{"mixed requested peers", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeChannel, ID: 55}},
|
||||
},
|
||||
}}, false},
|
||||
{"unrelated service", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionPhoneCall, Call: &domain.MessagePhoneCallAction{},
|
||||
}}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := botAPIMessageMediaProjectable(tt.media); got != tt.want {
|
||||
t.Fatalf("projectable=%v want=%v media=%#v", got, tt.want, tt.media)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMessagePeerRefsIncludesRequestedPeers(t *testing.T) {
|
||||
users := map[int64]struct{}{}
|
||||
channels := map[int64]struct{}{}
|
||||
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer,
|
||||
RequestedPeer: &domain.MessageRequestedPeerAction{ButtonID: 1, Peers: []domain.Peer{
|
||||
{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeChannel, ID: 55},
|
||||
}},
|
||||
},
|
||||
}}, 0, users, channels)
|
||||
if _, ok := users[1001]; !ok {
|
||||
t.Fatalf("requested user refs=%v", users)
|
||||
}
|
||||
if _, ok := channels[55]; !ok {
|
||||
t.Fatalf("requested channel refs=%v", channels)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -689,12 +689,15 @@ func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg
|
|||
case *tg.InputKeyboardButtonRequestPeer:
|
||||
out.ButtonID = b.ButtonID
|
||||
out.Text = strings.TrimSpace(b.Text)
|
||||
out.PeerType = requestPeerTypeName(b.PeerType)
|
||||
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
|
||||
out.MaxQuantity = b.MaxQuantity
|
||||
out.NameRequested = b.NameRequested
|
||||
out.UsernameRequested = b.UsernameRequested
|
||||
out.PhotoRequested = b.PhotoRequested
|
||||
case *tg.KeyboardButtonRequestPeer:
|
||||
out.ButtonID = b.ButtonID
|
||||
out.Text = strings.TrimSpace(b.Text)
|
||||
out.PeerType = requestPeerTypeName(b.PeerType)
|
||||
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
|
||||
out.MaxQuantity = b.MaxQuantity
|
||||
default:
|
||||
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
|
||||
|
|
@ -722,7 +725,7 @@ func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) tg.Key
|
|||
return &tg.KeyboardButtonRequestPeer{
|
||||
Text: button.Text,
|
||||
ButtonID: button.ButtonID,
|
||||
PeerType: tgRequestPeerType(button.PeerType),
|
||||
PeerType: tgRequestPeerTypeWithFilter(button.PeerType, button.PeerFilter),
|
||||
MaxQuantity: button.MaxQuantity,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,29 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// callbackRegistry 是 bot callback query 的进程内挂起表:messages.getBotCallbackAnswer
|
||||
// 注册一个 (query_id → chan),把 updateBotCallbackQuery 推给 bot 后阻塞等待;bot 经
|
||||
// messages.setBotCallbackAnswer 用同一 query_id 解挂。单实例可行;多实例需共享通道
|
||||
// (getBotCallbackAnswer 与 setBotCallbackAnswer 落不同实例则等不到 → 超时),记架构 todo。
|
||||
// callbackRegistry keeps local waiter channels and mirrors ownership/answers to
|
||||
// a short-lived shared store. The shared CAS is the source of truth when wired:
|
||||
// it lets getBotCallbackAnswer and answerCallbackQuery land on different nodes
|
||||
// without accepting two answers or trusting a process-local owner map.
|
||||
type callbackRegistry struct {
|
||||
mu sync.Mutex
|
||||
pending map[int64]*pendingCallback
|
||||
shared store.BotCallbackRegistryStore
|
||||
}
|
||||
|
||||
type pendingCallback struct {
|
||||
|
|
@ -26,35 +33,77 @@ type pendingCallback struct {
|
|||
userID int64
|
||||
}
|
||||
|
||||
func newCallbackRegistry() *callbackRegistry {
|
||||
return &callbackRegistry{pending: make(map[int64]*pendingCallback)}
|
||||
func newCallbackRegistry(shared ...store.BotCallbackRegistryStore) *callbackRegistry {
|
||||
var sharedStore store.BotCallbackRegistryStore
|
||||
if len(shared) > 0 {
|
||||
sharedStore = shared[0]
|
||||
}
|
||||
return &callbackRegistry{pending: make(map[int64]*pendingCallback), shared: sharedStore}
|
||||
}
|
||||
|
||||
// register 登记一次挂起的 callback,返回全局唯一 query_id 与接收通道。调用方必须
|
||||
// defer deregister(queryID),无论是否收到答案(超时三件套之一,防 goroutine/表泄漏)。
|
||||
func (c *callbackRegistry) register(botUserID, userID int64) (int64, *pendingCallback) {
|
||||
p := &pendingCallback{
|
||||
ch: make(chan domain.BotCallbackAnswer, 1),
|
||||
done: make(chan struct{}),
|
||||
botUserID: botUserID,
|
||||
userID: userID,
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var queryID int64
|
||||
for {
|
||||
queryID = randomNonZeroInt64()
|
||||
if _, exists := c.pending[queryID]; !exists {
|
||||
break
|
||||
queryID, pending, _ := c.registerContext(context.Background(), time.Now(), botUserID, userID, botCallbackTimeout)
|
||||
return queryID, pending
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) registerContext(ctx context.Context, now time.Time, botUserID, userID int64, ttl time.Duration) (int64, *pendingCallback, error) {
|
||||
for attempts := 0; attempts < 32; attempts++ {
|
||||
p := &pendingCallback{
|
||||
ch: make(chan domain.BotCallbackAnswer, 1),
|
||||
done: make(chan struct{}),
|
||||
botUserID: botUserID,
|
||||
userID: userID,
|
||||
}
|
||||
c.mu.Lock()
|
||||
queryID := randomNonZeroInt64()
|
||||
if _, exists := c.pending[queryID]; exists {
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
c.pending[queryID] = p
|
||||
c.mu.Unlock()
|
||||
if c.shared == nil {
|
||||
return queryID, p, nil
|
||||
}
|
||||
created, err := c.shared.PutBotCallbackPending(ctx, store.BotCallbackPending{
|
||||
QueryID: queryID, BotUserID: botUserID, UserID: userID, CreatedAt: now,
|
||||
}, ttl)
|
||||
if err != nil {
|
||||
c.removeLocal(queryID)
|
||||
return 0, nil, err
|
||||
}
|
||||
if created {
|
||||
return queryID, p, nil
|
||||
}
|
||||
c.removeLocal(queryID)
|
||||
}
|
||||
c.pending[queryID] = p
|
||||
return queryID, p
|
||||
return 0, nil, fmt.Errorf("allocate bot callback query id")
|
||||
}
|
||||
|
||||
// deregister 移除挂起条目并关闭 done(超时/解挂后必调,幂等)。关闭 done 让仍在
|
||||
// select 的等待者立即醒来,避免 resolve 把答案投递到一个等待者已离开的 ch(TOCTOU)。
|
||||
func (c *callbackRegistry) deregister(queryID int64) {
|
||||
c.deregisterContext(context.Background(), 0, queryID)
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) deregisterContext(ctx context.Context, botUserID, queryID int64) {
|
||||
c.mu.Lock()
|
||||
if p, ok := c.pending[queryID]; ok {
|
||||
if botUserID == 0 {
|
||||
botUserID = p.botUserID
|
||||
}
|
||||
delete(c.pending, queryID)
|
||||
close(p.done)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if c.shared != nil && botUserID > 0 {
|
||||
_ = c.shared.DeleteBotCallbackPending(ctx, botUserID, queryID)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) removeLocal(queryID int64) {
|
||||
c.mu.Lock()
|
||||
if p, ok := c.pending[queryID]; ok {
|
||||
delete(c.pending, queryID)
|
||||
|
|
@ -73,6 +122,23 @@ func (c *callbackRegistry) size() int {
|
|||
// resolve 把 bot 的答案投递给等待者。鉴权:仅该 query 的属主 bot 可解挂(callerBotID
|
||||
// 必须等于注册时的 botUserID,I6)。返回是否成功投递(query 未注册/已超时/非属主 → false)。
|
||||
func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool {
|
||||
resolved, _ := c.resolveContext(context.Background(), callerBotID, queryID, ans)
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) resolveContext(ctx context.Context, callerBotID, queryID int64, ans domain.BotCallbackAnswer) (bool, error) {
|
||||
if c.shared != nil {
|
||||
resolved, err := c.shared.ResolveBotCallback(ctx, callerBotID, queryID, ans)
|
||||
if err != nil || !resolved {
|
||||
return resolved, err
|
||||
}
|
||||
c.deliver(callerBotID, queryID, ans)
|
||||
return true, nil
|
||||
}
|
||||
return c.deliver(callerBotID, queryID, ans), nil
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) deliver(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool {
|
||||
c.mu.Lock()
|
||||
p, ok := c.pending[queryID]
|
||||
if !ok || p.botUserID != callerBotID {
|
||||
|
|
@ -89,6 +155,37 @@ func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCal
|
|||
return true
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) sharedAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) {
|
||||
if c.shared == nil {
|
||||
return domain.BotCallbackAnswer{}, false, nil
|
||||
}
|
||||
return c.shared.GetBotCallbackAnswer(ctx, botUserID, queryID)
|
||||
}
|
||||
|
||||
func (r *Router) RunBotCallbackAnswerSubscriber(ctx context.Context) {
|
||||
if r == nil || r.callbacks == nil || r.callbacks.shared == nil {
|
||||
return
|
||||
}
|
||||
for ctx.Err() == nil {
|
||||
err := r.callbacks.shared.SubscribeBotCallbackAnswers(ctx, func(_ context.Context, push store.BotCallbackAnswerPush) {
|
||||
r.callbacks.deliver(push.BotUserID, push.QueryID, push.Answer)
|
||||
})
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil && r.log != nil {
|
||||
r.log.Warn("bot callback answer subscriber disconnected", zap.Error(err))
|
||||
}
|
||||
timer := time.NewTimer(time.Second)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// randomNonZeroInt64 取密码学随机非零 int64。register 在持锁下调用,故此处禁止
|
||||
// 无限重试——熵源异常时退化为单调序列兜底(query_id 只需进程内唯一,register 的
|
||||
// 撞键复核会再保证唯一性),绝不卡住整个 registry。
|
||||
|
|
@ -125,3 +222,24 @@ func chatInstanceFor(botUserID, userID int64) int64 {
|
|||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// chatInstanceForPeer extends the stable hash to non-private chats without allowing a
|
||||
// channel id to collide with a numerically equal private user id.
|
||||
func chatInstanceForPeer(botUserID int64, peer domain.Peer) int64 {
|
||||
h := fnv.New64a()
|
||||
var buf [17]byte
|
||||
binary.LittleEndian.PutUint64(buf[0:8], uint64(botUserID))
|
||||
binary.LittleEndian.PutUint64(buf[8:16], uint64(peer.ID))
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeChannel:
|
||||
buf[16] = 2
|
||||
default:
|
||||
buf[16] = 1
|
||||
}
|
||||
_, _ = h.Write(buf[:])
|
||||
v := int64(h.Sum64())
|
||||
if v == 0 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
|
|
|||
|
|
@ -800,21 +800,23 @@ func tgAdminLogMessage(viewerUserID, channelID int64, msg *domain.ChannelMessage
|
|||
|
||||
func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights {
|
||||
return tg.ChatAdminRights{
|
||||
ChangeInfo: rights.ChangeInfo,
|
||||
PostMessages: rights.PostMessages,
|
||||
EditMessages: rights.EditMessages,
|
||||
DeleteMessages: rights.DeleteMessages,
|
||||
PostStories: rights.PostStories,
|
||||
EditStories: rights.EditStories,
|
||||
DeleteStories: rights.DeleteStories,
|
||||
BanUsers: rights.BanUsers,
|
||||
InviteUsers: rights.InviteUsers,
|
||||
PinMessages: rights.PinMessages,
|
||||
AddAdmins: rights.AddAdmins,
|
||||
Anonymous: rights.Anonymous,
|
||||
ManageCall: rights.ManageCall,
|
||||
Other: true,
|
||||
ManageRanks: rights.ManageRanks,
|
||||
ChangeInfo: rights.ChangeInfo,
|
||||
PostMessages: rights.PostMessages,
|
||||
EditMessages: rights.EditMessages,
|
||||
DeleteMessages: rights.DeleteMessages,
|
||||
PostStories: rights.PostStories,
|
||||
EditStories: rights.EditStories,
|
||||
DeleteStories: rights.DeleteStories,
|
||||
BanUsers: rights.BanUsers,
|
||||
InviteUsers: rights.InviteUsers,
|
||||
PinMessages: rights.PinMessages,
|
||||
AddAdmins: rights.AddAdmins,
|
||||
Anonymous: rights.Anonymous,
|
||||
ManageCall: rights.ManageCall,
|
||||
Other: true,
|
||||
ManageTopics: rights.ManageTopics,
|
||||
ManageRanks: rights.ManageRanks,
|
||||
ManageLinkedPeers: rights.ManageLinkedPeers,
|
||||
// manage_direct_messages(flags.17):客户端据此在母频道上判定 canAccessMonoforum,
|
||||
// 从而为关联 monoforum 派生 MonoforumAdmin(Direct-Messages 容器渲染所需)。
|
||||
ManageDirectMessages: rights.ManageDirectMessages,
|
||||
|
|
@ -840,7 +842,10 @@ func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRigh
|
|||
AddAdmins: rights.AddAdmins,
|
||||
Anonymous: rights.Anonymous,
|
||||
ManageCall: rights.ManageCall,
|
||||
ManageChat: rights.Other,
|
||||
ManageTopics: rights.ManageTopics,
|
||||
ManageRanks: rights.ManageRanks,
|
||||
ManageLinkedPeers: rights.ManageLinkedPeers,
|
||||
ManageDirectMessages: rights.ManageDirectMessages,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_markup(inline
|
||||
// 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-reply:P3 不支持,丢弃。
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
171
internal/rpc/convert_markup_test.go
Normal file
171
internal/rpc/convert_markup_test.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestReplyKeyboardTLDomainRoundTrip(t *testing.T) {
|
||||
in := &tg.ReplyKeyboardMarkup{
|
||||
Resize: true,
|
||||
SingleUse: true,
|
||||
Selective: true,
|
||||
Persistent: true,
|
||||
Placeholder: "Choose",
|
||||
Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{
|
||||
&tg.KeyboardButton{Text: "Help"},
|
||||
func() *tg.KeyboardButton {
|
||||
button := &tg.KeyboardButton{Text: "Status"}
|
||||
style := tg.KeyboardButtonStyle{}
|
||||
style.SetBgPrimary(true)
|
||||
style.SetIcon(123456)
|
||||
button.SetStyle(style)
|
||||
return button
|
||||
}(),
|
||||
}}},
|
||||
}
|
||||
got, err := domainOutgoingReplyMarkupForSender(in, true)
|
||||
if err != nil {
|
||||
t.Fatalf("domainOutgoingReplyMarkupForSender: %v", err)
|
||||
}
|
||||
if got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard || len(got.Keyboard) != 1 ||
|
||||
len(got.Keyboard[0]) != 2 || got.Keyboard[0][0].Text != "Help" || !got.Resize ||
|
||||
!got.SingleUse || !got.Selective || !got.Persistent || got.Placeholder != "Choose" {
|
||||
t.Fatalf("domain markup = %#v", got)
|
||||
}
|
||||
if got.Keyboard[0][1].Style != domain.MarkupButtonStylePrimary || got.Keyboard[0][1].IconCustomEmojiID != 123456 {
|
||||
t.Fatalf("second button decoration = %#v", got.Keyboard[0][1])
|
||||
}
|
||||
wire, ok := tgReplyMarkup(got).(*tg.ReplyKeyboardMarkup)
|
||||
if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 2 {
|
||||
t.Fatalf("wire markup = %#v", wire)
|
||||
}
|
||||
if button, ok := wire.Rows[0].Buttons[1].(*tg.KeyboardButton); !ok || button.Text != "Status" {
|
||||
t.Fatalf("second button = %#v", wire.Rows[0].Buttons[1])
|
||||
} else if style, ok := button.GetStyle(); !ok || !style.GetBgPrimary() || style.Icon != 123456 {
|
||||
t.Fatalf("second button style = %#v ok=%v", style, ok)
|
||||
}
|
||||
if !wire.Resize || !wire.SingleUse || !wire.Selective || !wire.Persistent || wire.Placeholder != "Choose" {
|
||||
t.Fatalf("wire flags = %#v", wire)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineButtonStyleTLDomainRoundTrip(t *testing.T) {
|
||||
button := &tg.KeyboardButtonCallback{Text: "Delete", Data: []byte("delete")}
|
||||
style := tg.KeyboardButtonStyle{}
|
||||
style.SetBgDanger(true)
|
||||
button.SetStyle(style)
|
||||
got, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("domainReplyMarkupForSender: %v", err)
|
||||
}
|
||||
if got.Inline[0][0].Style != domain.MarkupButtonStyleDanger {
|
||||
t.Fatalf("domain style = %#v", got.Inline[0][0])
|
||||
}
|
||||
wire := tgReplyMarkup(got).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonCallback)
|
||||
if roundTrip, ok := wire.GetStyle(); !ok || !roundTrip.GetBgDanger() {
|
||||
t.Fatalf("wire style = %#v ok=%v", roundTrip, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyKeyboardHideAndForceReplyTLDomainRoundTrip(t *testing.T) {
|
||||
hide, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardHide{Selective: true}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("hide parse: %v", err)
|
||||
}
|
||||
if wire, ok := tgReplyMarkup(hide).(*tg.ReplyKeyboardHide); !ok || !wire.Selective {
|
||||
t.Fatalf("hide wire = %#v", wire)
|
||||
}
|
||||
force, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardForceReply{
|
||||
SingleUse: true, Selective: true, Placeholder: "Answer",
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("force parse: %v", err)
|
||||
}
|
||||
if wire, ok := tgReplyMarkup(force).(*tg.ReplyKeyboardForceReply); !ok || !wire.SingleUse || !wire.Selective || wire.Placeholder != "Answer" {
|
||||
t.Fatalf("force wire = %#v", wire)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyKeyboardRequestPhoneTLDomainRoundTrip(t *testing.T) {
|
||||
markup, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{
|
||||
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonRequestPhone{Text: "Share phone"}},
|
||||
}}}, true)
|
||||
if err != nil || markup == nil || len(markup.Keyboard) != 1 || len(markup.Keyboard[0]) != 1 ||
|
||||
markup.Keyboard[0][0].Type != domain.MarkupButtonRequestPhone {
|
||||
t.Fatalf("request_phone markup = %#v err=%v", markup, err)
|
||||
}
|
||||
wire, ok := tgReplyMarkup(markup).(*tg.ReplyKeyboardMarkup)
|
||||
if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 1 {
|
||||
t.Fatalf("request_phone wire = %#v", wire)
|
||||
}
|
||||
if _, ok := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPhone); !ok {
|
||||
t.Fatalf("request_phone button = %#v", wire.Rows[0].Buttons[0])
|
||||
}
|
||||
if _, err := domainReplyMarkupForSender(&tg.ReplyKeyboardHide{}, true); err == nil {
|
||||
t.Fatal("inline-only edit/result parser must reject reply-keyboard constructors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyKeyboardRequestPeerFiltersTLDomainRoundTrip(t *testing.T) {
|
||||
userType := &tg.RequestPeerTypeUser{}
|
||||
userType.SetBot(false)
|
||||
userType.SetPremium(true)
|
||||
chatType := &tg.RequestPeerTypeChat{Creator: true, BotParticipant: true}
|
||||
chatType.SetHasUsername(false)
|
||||
chatType.SetForum(true)
|
||||
chatType.SetUserAdminRights(tg.ChatAdminRights{DeleteMessages: true, ManageTopics: true})
|
||||
in := &tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{
|
||||
&tg.KeyboardButtonRequestPeer{Text: "Premium person", ButtonID: 1, PeerType: userType, MaxQuantity: 2},
|
||||
&tg.KeyboardButtonRequestPeer{Text: "Forum", ButtonID: 2, PeerType: chatType, MaxQuantity: 1},
|
||||
}}}}
|
||||
markup, err := domainOutgoingReplyMarkupForSender(in, true)
|
||||
if err != nil {
|
||||
t.Fatalf("parse request peer filters: %v", err)
|
||||
}
|
||||
userFilter := markup.Keyboard[0][0].RequestPeerFilter
|
||||
chatFilter := markup.Keyboard[0][1].RequestPeerFilter
|
||||
if userFilter == nil || !userFilter.UserIsBotSet || userFilter.UserIsBot || !userFilter.UserIsPremiumSet || !userFilter.UserIsPremium {
|
||||
t.Fatalf("user filter = %#v", userFilter)
|
||||
}
|
||||
if chatFilter == nil || !chatFilter.ChatIsCreated || !chatFilter.BotIsMember || !chatFilter.ChatHasUsernameSet ||
|
||||
chatFilter.ChatHasUsername || !chatFilter.ChatIsForumSet || !chatFilter.ChatIsForum ||
|
||||
chatFilter.UserAdminRights == nil || !chatFilter.UserAdminRights.DeleteMessages || !chatFilter.UserAdminRights.ManageTopics {
|
||||
t.Fatalf("chat filter = %#v", chatFilter)
|
||||
}
|
||||
wire := tgReplyMarkup(markup).(*tg.ReplyKeyboardMarkup)
|
||||
wireUser := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeUser)
|
||||
if bot, ok := wireUser.GetBot(); !ok || bot {
|
||||
t.Fatalf("wire user bot=%v ok=%v", bot, ok)
|
||||
}
|
||||
if premium, ok := wireUser.GetPremium(); !ok || !premium {
|
||||
t.Fatalf("wire user premium=%v ok=%v", premium, ok)
|
||||
}
|
||||
wireChat := wire.Rows[0].Buttons[1].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeChat)
|
||||
if !wireChat.Creator || !wireChat.BotParticipant {
|
||||
t.Fatalf("wire chat = %#v", wireChat)
|
||||
}
|
||||
if hasUsername, ok := wireChat.GetHasUsername(); !ok || hasUsername {
|
||||
t.Fatalf("wire has_username=%v ok=%v", hasUsername, ok)
|
||||
}
|
||||
if rights, ok := wireChat.GetUserAdminRights(); !ok || !rights.DeleteMessages || !rights.ManageTopics {
|
||||
t.Fatalf("wire rights=%#v ok=%v", rights, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputRequestPeerButtonPreservesRequestedMetadata(t *testing.T) {
|
||||
button := &tg.InputKeyboardButtonRequestPeer{
|
||||
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
|
||||
Text: "Share", ButtonID: 99, PeerType: &tg.RequestPeerTypeUser{}, MaxQuantity: 3,
|
||||
}
|
||||
got, err := domainRequestedButtonFromTG(1001, nil, button)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.NameRequested || !got.UsernameRequested || !got.PhotoRequested || got.MaxQuantity != 3 {
|
||||
t.Fatalf("requested button=%#v", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -112,7 +112,7 @@ func tgMessage(m domain.Message) tg.MessageClass {
|
|||
msg.SetInvertMedia(true)
|
||||
}
|
||||
}
|
||||
// reply_markup(bot inline keyboard):仅普通 tg.Message 携带(service 消息不带)。
|
||||
// reply_markup(bot reply/inline keyboard):仅普通 tg.Message 携带(service 消息不带)。
|
||||
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
|
||||
msg.SetReplyMarkup(markup)
|
||||
}
|
||||
|
|
@ -211,7 +211,7 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
|
|||
if msg.Out {
|
||||
return &tg.MessageActionRequestedPeerSentMe{
|
||||
ButtonID: shared.ButtonID,
|
||||
Peers: tgRequestedPeers(shared.Peers),
|
||||
Peers: tgRequestedPeers(shared),
|
||||
}
|
||||
}
|
||||
return &tg.MessageActionRequestedPeer{
|
||||
|
|
@ -294,14 +294,43 @@ func tgPeerList(peers []domain.Peer) []tg.PeerClass {
|
|||
return out
|
||||
}
|
||||
|
||||
func tgRequestedPeers(peers []domain.Peer) []tg.RequestedPeerClass {
|
||||
out := make([]tg.RequestedPeerClass, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
func tgRequestedPeers(action *domain.MessageRequestedPeerAction) []tg.RequestedPeerClass {
|
||||
if action == nil {
|
||||
return nil
|
||||
}
|
||||
details := make(map[domain.Peer]domain.MessageRequestedPeerDetails, len(action.Details))
|
||||
for _, detail := range action.Details {
|
||||
details[detail.Peer] = detail
|
||||
}
|
||||
out := make([]tg.RequestedPeerClass, 0, len(action.Peers))
|
||||
for _, peer := range action.Peers {
|
||||
detail := details[peer]
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
out = append(out, &tg.RequestedPeerUser{UserID: peer.ID})
|
||||
item := &tg.RequestedPeerUser{UserID: peer.ID}
|
||||
if action.NameRequested {
|
||||
item.SetFirstName(detail.FirstName)
|
||||
item.SetLastName(detail.LastName)
|
||||
}
|
||||
if action.UsernameRequested {
|
||||
item.SetUsername(detail.Username)
|
||||
}
|
||||
if action.PhotoRequested && detail.Photo != nil {
|
||||
item.SetPhoto(tgPhoto(*detail.Photo))
|
||||
}
|
||||
out = append(out, item)
|
||||
case domain.PeerTypeChannel:
|
||||
out = append(out, &tg.RequestedPeerChannel{ChannelID: peer.ID})
|
||||
item := &tg.RequestedPeerChannel{ChannelID: peer.ID}
|
||||
if action.NameRequested {
|
||||
item.SetTitle(detail.Title)
|
||||
}
|
||||
if action.UsernameRequested {
|
||||
item.SetUsername(detail.Username)
|
||||
}
|
||||
if action.PhotoRequested && detail.Photo != nil {
|
||||
item.SetPhoto(tgPhoto(*detail.Photo))
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -821,6 +821,7 @@ type Deps struct {
|
|||
Updates UpdatesService
|
||||
BootstrapUpdates store.BootstrapUpdateJobStore
|
||||
BotAPIUpdates store.BotAPIUpdateStore
|
||||
BotCallbacks store.BotCallbackRegistryStore
|
||||
Contacts ContactsService
|
||||
Dialogs DialogsService
|
||||
Chatlists ChatlistsService
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
|
@ -67,6 +68,7 @@ func (r *Router) onMessagesSendWebViewData(ctx context.Context, req *tg.Messages
|
|||
if err != nil {
|
||||
return nil, messageSendErr(err)
|
||||
}
|
||||
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res)
|
||||
var users []tg.UserClass
|
||||
var chats []tg.ChatClass
|
||||
if !res.Duplicate {
|
||||
|
|
@ -101,16 +103,27 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
|
|||
if !found || !botUser.Bot {
|
||||
return nil, botInvalidErr()
|
||||
}
|
||||
webAppReqID, ok := req.GetWebappReqID()
|
||||
if !ok || webAppReqID == "" {
|
||||
return nil, buttonDataInvalidErr()
|
||||
}
|
||||
button, found, err := r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || button.ButtonID != req.ButtonID {
|
||||
return nil, buttonDataInvalidErr()
|
||||
webAppReqID, fromWebApp := req.GetWebappReqID()
|
||||
idempotencyKey := webAppReqID
|
||||
var button domain.BotRequestedWebViewButton
|
||||
if fromWebApp {
|
||||
if webAppReqID == "" {
|
||||
return nil, buttonDataInvalidErr()
|
||||
}
|
||||
var found bool
|
||||
button, found, err = r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || button.ButtonID != req.ButtonID {
|
||||
return nil, buttonDataInvalidErr()
|
||||
}
|
||||
} else {
|
||||
idempotencyKey = "message:" + strconv.Itoa(req.MsgID)
|
||||
button, err = r.requestPeerButtonFromMessage(ctx, userID, botUser.ID, req.MsgID, req.ButtonID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(req.RequestedPeers) == 0 || len(req.RequestedPeers) > button.MaxQuantity {
|
||||
return nil, buttonDataInvalidErr()
|
||||
|
|
@ -121,11 +134,17 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !requestedPeerTypeMatches(button.PeerType, resolved) {
|
||||
if matches, err := r.requestedPeerMatches(ctx, userID, botUser.ID, button, resolved); err != nil {
|
||||
return nil, internalErr()
|
||||
} else if !matches {
|
||||
return nil, buttonDataInvalidErr()
|
||||
}
|
||||
peers = append(peers, resolved)
|
||||
}
|
||||
details, err := r.requestedPeerDetails(ctx, userID, peers, button)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
recipientBlocked, err := r.peerBlocksUser(ctx, userID, botUser.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -134,14 +153,18 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
|
|||
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: userID,
|
||||
RecipientUserID: botUser.ID,
|
||||
RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, webAppReqID, button.ButtonID, peers),
|
||||
RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, idempotencyKey, button.ButtonID, peers),
|
||||
Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer,
|
||||
RequestedPeer: &domain.MessageRequestedPeerAction{
|
||||
ButtonID: button.ButtonID,
|
||||
Peers: peers,
|
||||
ButtonID: button.ButtonID,
|
||||
Peers: peers,
|
||||
Details: details,
|
||||
NameRequested: button.NameRequested,
|
||||
UsernameRequested: button.UsernameRequested,
|
||||
PhotoRequested: button.PhotoRequested,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -153,7 +176,10 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
_ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
|
||||
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res)
|
||||
if fromWebApp {
|
||||
_ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
|
||||
}
|
||||
var users []tg.UserClass
|
||||
var chats []tg.ChatClass
|
||||
if !res.Duplicate {
|
||||
|
|
@ -163,6 +189,128 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
|
|||
return tgPrivateSendResultUpdates(res, res.SenderMessage.RandomID, false, users, chats), nil
|
||||
}
|
||||
|
||||
type requestedPeerPhotoProvider interface {
|
||||
GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error)
|
||||
}
|
||||
|
||||
func (r *Router) requestedPeerDetails(ctx context.Context, viewerUserID int64, peers []domain.Peer, button domain.BotRequestedWebViewButton) ([]domain.MessageRequestedPeerDetails, error) {
|
||||
details := make([]domain.MessageRequestedPeerDetails, len(peers))
|
||||
for i, peer := range peers {
|
||||
details[i].Peer = peer
|
||||
}
|
||||
if !button.NameRequested && !button.UsernameRequested && !button.PhotoRequested {
|
||||
return details, nil
|
||||
}
|
||||
userIDs := make(map[int64]struct{})
|
||||
channelIDs := make(map[int64]struct{})
|
||||
for _, peer := range peers {
|
||||
addDomainPeerRef(peer, 0, userIDs, channelIDs)
|
||||
}
|
||||
cache := newViewerPeerCache(r)
|
||||
users := cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs))
|
||||
channels := cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))
|
||||
userByID := make(map[int64]domain.User, len(users))
|
||||
channelByID := make(map[int64]domain.Channel, len(channels))
|
||||
photoIDs := make([]int64, 0, len(peers))
|
||||
for _, user := range users {
|
||||
userByID[user.ID] = user
|
||||
if button.PhotoRequested && user.PhotoID != 0 {
|
||||
photoIDs = append(photoIDs, user.PhotoID)
|
||||
}
|
||||
}
|
||||
for _, channel := range channels {
|
||||
channelByID[channel.ID] = channel
|
||||
if button.PhotoRequested && channel.PhotoID != 0 {
|
||||
photoIDs = append(photoIDs, channel.PhotoID)
|
||||
}
|
||||
}
|
||||
photoByID := make(map[int64]domain.Photo, len(photoIDs))
|
||||
if len(photoIDs) > 0 {
|
||||
provider, ok := r.deps.Files.(requestedPeerPhotoProvider)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("requested peer photo provider unavailable")
|
||||
}
|
||||
photos, err := provider.GetPhotos(ctx, photoIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, photo := range photos {
|
||||
photoByID[photo.ID] = photo
|
||||
}
|
||||
}
|
||||
for i, peer := range peers {
|
||||
detail := &details[i]
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
user, ok := userByID[peer.ID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("requested user %d not hydrated", peer.ID)
|
||||
}
|
||||
if button.NameRequested {
|
||||
detail.FirstName, detail.LastName = user.FirstName, user.LastName
|
||||
}
|
||||
if button.UsernameRequested {
|
||||
detail.Username = user.Username
|
||||
}
|
||||
if button.PhotoRequested && user.PhotoID != 0 {
|
||||
photo, ok := photoByID[user.PhotoID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("requested user photo %d missing", user.PhotoID)
|
||||
}
|
||||
detail.Photo = &photo
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
channel, ok := channelByID[peer.ID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("requested channel %d not hydrated", peer.ID)
|
||||
}
|
||||
if button.NameRequested {
|
||||
detail.Title = channel.Title
|
||||
}
|
||||
if button.UsernameRequested {
|
||||
detail.Username = channel.Username
|
||||
}
|
||||
if button.PhotoRequested && channel.PhotoID != 0 {
|
||||
photo, ok := photoByID[channel.PhotoID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("requested channel photo %d missing", channel.PhotoID)
|
||||
}
|
||||
detail.Photo = &photo
|
||||
}
|
||||
}
|
||||
}
|
||||
return details, nil
|
||||
}
|
||||
|
||||
func (r *Router) requestPeerButtonFromMessage(ctx context.Context, userID, botUserID int64, messageID, buttonID int) (domain.BotRequestedWebViewButton, error) {
|
||||
if messageID <= 0 || messageID > domain.MaxMessageBoxID || buttonID == 0 {
|
||||
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
|
||||
}
|
||||
message, found, err := r.lookupOwnerMessage(ctx, userID, messageID)
|
||||
if err != nil {
|
||||
return domain.BotRequestedWebViewButton{}, internalErr()
|
||||
}
|
||||
if !found || message.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: botUserID}) ||
|
||||
message.From != (domain.Peer{Type: domain.PeerTypeUser, ID: botUserID}) || message.ReplyMarkup == nil ||
|
||||
message.ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard {
|
||||
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
|
||||
}
|
||||
for _, row := range message.ReplyMarkup.Keyboard {
|
||||
for _, item := range row {
|
||||
if item.Type != domain.MarkupButtonRequestPeer || item.ButtonID != buttonID {
|
||||
continue
|
||||
}
|
||||
return domain.BotRequestedWebViewButton{
|
||||
BotUserID: botUserID, UserID: userID, ButtonID: item.ButtonID,
|
||||
PeerType: item.RequestPeerType, MaxQuantity: item.MaxQuantity, PeerFilter: item.RequestPeerFilter,
|
||||
NameRequested: item.NameRequested, UsernameRequested: item.UsernameRequested,
|
||||
PhotoRequested: item.PhotoRequested,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
|
||||
}
|
||||
|
||||
func requestedPeerTypeMatches(kind string, peer domain.Peer) bool {
|
||||
switch kind {
|
||||
case "user", "":
|
||||
|
|
@ -174,6 +322,97 @@ func requestedPeerTypeMatches(kind string, peer domain.Peer) bool {
|
|||
}
|
||||
}
|
||||
|
||||
func (r *Router) requestedPeerMatches(ctx context.Context, userID, botUserID int64, button domain.BotRequestedWebViewButton, peer domain.Peer) (bool, error) {
|
||||
if !requestedPeerTypeMatches(button.PeerType, peer) {
|
||||
return false, nil
|
||||
}
|
||||
filter := button.PeerFilter
|
||||
if filter == nil {
|
||||
return true, nil
|
||||
}
|
||||
if peer.Type == domain.PeerTypeUser {
|
||||
if r.deps.Users == nil {
|
||||
return false, nil
|
||||
}
|
||||
user, found, err := r.deps.Users.ByID(ctx, userID, peer.ID)
|
||||
if err != nil || !found {
|
||||
return false, err
|
||||
}
|
||||
if filter.UserIsBotSet && user.Bot != filter.UserIsBot {
|
||||
return false, nil
|
||||
}
|
||||
if filter.UserIsPremiumSet && user.PremiumActiveAt(r.clock.Now().Unix()) != filter.UserIsPremium {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if r.deps.Channels == nil {
|
||||
return false, nil
|
||||
}
|
||||
view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
channel := view.Channel
|
||||
if button.PeerType == "chat" && (!channel.Megagroup || channel.Broadcast) {
|
||||
return false, nil
|
||||
}
|
||||
if button.PeerType == "broadcast" && !channel.Broadcast {
|
||||
return false, nil
|
||||
}
|
||||
if filter.ChatHasUsernameSet && (channel.Username != "") != filter.ChatHasUsername {
|
||||
return false, nil
|
||||
}
|
||||
if filter.ChatIsForumSet && channel.Forum != filter.ChatIsForum {
|
||||
return false, nil
|
||||
}
|
||||
if filter.ChatIsCreated && view.Self.Role != domain.ChannelRoleCreator {
|
||||
return false, nil
|
||||
}
|
||||
if filter.UserAdminRights != nil && !channelMemberHasRequestRights(view.Self, *filter.UserAdminRights) {
|
||||
return false, nil
|
||||
}
|
||||
if filter.BotIsMember || filter.BotAdminRights != nil {
|
||||
botMember, err := r.deps.Channels.GetParticipant(ctx, userID, peer.ID, botUserID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if botMember.Status != domain.ChannelMemberActive {
|
||||
return false, nil
|
||||
}
|
||||
if filter.BotAdminRights != nil && !channelMemberHasRequestRights(botMember, *filter.BotAdminRights) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func channelMemberHasRequestRights(member domain.ChannelMember, required domain.BotRequestAdminRights) bool {
|
||||
if member.Role == domain.ChannelRoleCreator {
|
||||
return true
|
||||
}
|
||||
if member.Role != domain.ChannelRoleAdmin {
|
||||
return false
|
||||
}
|
||||
rights := member.AdminRights
|
||||
return (!required.Anonymous || rights.Anonymous) &&
|
||||
(!required.ManageChat || rights.ManageChat) &&
|
||||
(!required.DeleteMessages || rights.DeleteMessages) &&
|
||||
(!required.ManageVideoChats || rights.ManageCall) &&
|
||||
(!required.RestrictMembers || rights.BanUsers) &&
|
||||
(!required.PromoteMembers || rights.AddAdmins) &&
|
||||
(!required.ChangeInfo || rights.ChangeInfo) &&
|
||||
(!required.InviteUsers || rights.InviteUsers) &&
|
||||
(!required.PostStories || rights.PostStories) &&
|
||||
(!required.EditStories || rights.EditStories) &&
|
||||
(!required.DeleteStories || rights.DeleteStories) &&
|
||||
(!required.PostMessages || rights.PostMessages) &&
|
||||
(!required.EditMessages || rights.EditMessages) &&
|
||||
(!required.PinMessages || rights.PinMessages) &&
|
||||
(!required.ManageTopics || rights.ManageTopics) &&
|
||||
(!required.ManageDirectMessages || rights.ManageDirectMessages)
|
||||
}
|
||||
|
||||
func botRequestedPeerServiceMessageRandomID(userID, botUserID int64, reqID string, buttonID int, peers []domain.Peer) int64 {
|
||||
parts := []string{"bot-requested-peer", strconv.FormatInt(userID, 10), strconv.FormatInt(botUserID, 10), reqID, strconv.Itoa(buttonID)}
|
||||
for _, peer := range peers {
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import (
|
|||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newInlineBotRPCTestFixture(t)
|
||||
f.router.deps.BotAPIUpdates = memory.NewBotAPIUpdateStore()
|
||||
ownerCtx := WithUserID(ctx, f.owner.ID)
|
||||
|
||||
updatesClass, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{
|
||||
|
|
@ -46,6 +48,13 @@ func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) {
|
|||
if service.PeerID.(*tg.PeerUser).UserID != f.bot.ID || service.FromID.(*tg.PeerUser).UserID != f.owner.ID {
|
||||
t.Fatalf("service peer/from = %+v/%+v, want bot/user", service.PeerID, service.FromID)
|
||||
}
|
||||
botAPIEvents, err := f.router.BotAPIUpdates(ctx, f.bot.ID, 0)
|
||||
if err != nil || len(botAPIEvents) != 1 || botAPIEvents[0].Message.Media == nil ||
|
||||
botAPIEvents[0].Message.Media.ServiceAction == nil ||
|
||||
botAPIEvents[0].Message.Media.ServiceAction.WebViewData == nil ||
|
||||
botAPIEvents[0].Message.Media.ServiceAction.WebViewData.Data != `{"ok":true}` {
|
||||
t.Fatalf("bot api webview events=%#v err=%v", botAPIEvents, err)
|
||||
}
|
||||
|
||||
botHistory, err := f.router.deps.Messages.GetHistory(ctx, f.bot.ID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
|
|
@ -140,6 +149,41 @@ func TestMessagesSendBotRequestedPeerRejectsWithoutRequestButtonState(t *testing
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessagesSendBotRequestedPeerQueuesBotAPIResponse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newInlineBotRPCTestFixture(t)
|
||||
f.router.deps.BotAPIUpdates = memory.NewBotAPIUpdateStore()
|
||||
ownerCtx := WithUserID(ctx, f.owner.ID)
|
||||
button := domain.MarkupButton{
|
||||
Type: domain.MarkupButtonRequestPeer, Text: "Share user", ButtonID: 77,
|
||||
RequestPeerType: "user", MaxQuantity: 1, NameRequested: true, UsernameRequested: true,
|
||||
}
|
||||
requestMessage, err := f.router.deps.Messages.SendPrivateText(ctx, f.bot.ID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: f.bot.ID, RecipientUserID: f.owner.ID, RandomID: 7001, Message: "Choose",
|
||||
ReplyMarkup: &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupKeyboard, Keyboard: [][]domain.MarkupButton{{button}}},
|
||||
Date: 1700000100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send request message: %v", err)
|
||||
}
|
||||
if _, err := f.router.onMessagesSendBotRequestedPeer(ownerCtx, &tg.MessagesSendBotRequestedPeerRequest{
|
||||
Peer: inputPeerUser(f.bot), MsgID: requestMessage.RecipientMessage.ID, ButtonID: button.ButtonID,
|
||||
RequestedPeers: []tg.InputPeerClass{inputPeerUser(f.peer)},
|
||||
}); err != nil {
|
||||
t.Fatalf("send requested peer: %v", err)
|
||||
}
|
||||
events, err := f.router.BotAPIUpdates(ctx, f.bot.ID, 0)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("bot api requested-peer events=%#v err=%v", events, err)
|
||||
}
|
||||
action := events[0].Message.Media.ServiceAction.RequestedPeer
|
||||
if action == nil || action.ButtonID != 77 || len(action.Peers) != 1 || action.Peers[0].ID != f.peer.ID ||
|
||||
len(action.Details) != 1 || action.Details[0].Peer != action.Peers[0] || action.Details[0].FirstName != f.peer.FirstName ||
|
||||
!action.NameRequested || !action.UsernameRequested {
|
||||
t.Fatalf("requested-peer action=%#v", action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesGetPreparedInlineMessageRejectsMissingRegistry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newInlineBotRPCTestFixture(t)
|
||||
|
|
|
|||
|
|
@ -169,15 +169,20 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
|||
sendErr = err
|
||||
return nil, sendErr
|
||||
}
|
||||
// reply_markup(bot inline keyboard):仅 bot 账号发送被接受+校验;非 bot 静默丢弃。
|
||||
// reply_markup:bot 可发送 inline keyboard 与普通 reply keyboard/hide/force;
|
||||
// 非 bot 静默丢弃。仅请求携带 markup 时查询 is_bot。
|
||||
// 仅在请求携带 markup 时才查 is_bot,避免普通发送多打一次查询。
|
||||
var replyMarkup *domain.MessageReplyMarkup
|
||||
if req.ReplyMarkup != nil {
|
||||
replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
|
||||
replyMarkup, err = domainOutgoingReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
|
||||
if err != nil {
|
||||
sendErr = replyMarkupErr(err)
|
||||
return nil, sendErr
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil {
|
||||
sendErr = err
|
||||
return nil, sendErr
|
||||
}
|
||||
}
|
||||
// rich_message(Layer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。
|
||||
// Phase 1 仅认 inputRichMessage(blocks 形态),HTML/Markdown 变体返回错误。
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ type Config struct {
|
|||
|
||||
// Router 把解密后的 RPC 请求按 semantic method 路由到 typed handler(tlprofile.Dispatcher)。
|
||||
//
|
||||
// handler 输入输出均为 iamxvbaba/td/tg 类型,各业务域的 handler
|
||||
// handler 输入输出均为 gotd/td/tg 类型,各业务域的 handler
|
||||
// 与注册见 help.go / auth.go / users.go / updates.go。Router 本身只负责协议外壳:
|
||||
// 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates / invokeAfter*,并兜底未注册 RPC。
|
||||
type Router struct {
|
||||
|
|
@ -237,7 +237,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
|
|||
if instanceID == "" {
|
||||
instanceID = fmt.Sprintf("%016x", randomNonZeroInt64())
|
||||
}
|
||||
r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID}
|
||||
r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(deps.BotCallbacks), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID}
|
||||
r.channelFanout = newChannelFanoutDispatcher(r, defaultChannelFanoutShards, defaultChannelFanoutBuffer)
|
||||
r.botAPIEnqueueQueue = newBotAPIEnqueueDispatcher(log, defaultBotAPIEnqueueBuffer)
|
||||
r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ type outgoingSend struct {
|
|||
sendAs *domain.Peer
|
||||
sendAsReady bool
|
||||
clearDraft bool
|
||||
// replyMarkup 是 bot inline keyboard(已解析+校验;非 bot 恒 nil)。
|
||||
// replyMarkup 是 bot reply/inline keyboard(已解析+校验;非 bot 恒 nil)。
|
||||
replyMarkup *domain.MessageReplyMarkup
|
||||
viaBotID int64
|
||||
// richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil)。
|
||||
|
|
@ -382,13 +382,17 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
|
|||
if media == nil {
|
||||
return nil, mediaInvalidErr()
|
||||
}
|
||||
// reply_markup(bot inline keyboard on media):仅 bot 接受+校验,非 bot 静默丢弃。
|
||||
// reply_markup:bot 可发送 inline keyboard 与普通 reply keyboard/hide/force;
|
||||
// 非 bot 静默丢弃。
|
||||
var replyMarkup *domain.MessageReplyMarkup
|
||||
if req.ReplyMarkup != nil {
|
||||
replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
|
||||
replyMarkup, err = domainOutgoingReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
|
||||
if err != nil {
|
||||
return nil, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
|
||||
return r.scheduleOutgoing(ctx, userID, peer, outgoingSend{
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser
|
|||
addDomainPeerRef(peer, 0, userIDs, channelIDs)
|
||||
}
|
||||
collectMessagePeerRefs(out[i].Message, 0, userIDs, channelIDs)
|
||||
if out[i].BotCallbackQuery != nil && out[i].BotCallbackQuery.UserID != 0 {
|
||||
userIDs[out[i].BotCallbackQuery.UserID] = struct{}{}
|
||||
}
|
||||
removeKnownChannelRefs(channelIDs, out[i].Channels)
|
||||
refs[i] = updateEventPeerRefs{userIDs: userIDs, channelIDs: channelIDs}
|
||||
for id := range userIDs {
|
||||
|
|
@ -220,6 +223,11 @@ func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs,
|
|||
if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 {
|
||||
userIDs[msg.Media.Contact.UserID] = struct{}{}
|
||||
}
|
||||
if msg.Media != nil && msg.Media.ServiceAction != nil && msg.Media.ServiceAction.RequestedPeer != nil {
|
||||
for _, peer := range msg.Media.ServiceAction.RequestedPeer.Peers {
|
||||
addDomainPeerRef(peer, currentChannelID, userIDs, channelIDs)
|
||||
}
|
||||
}
|
||||
collectPollMediaUserRefs(msg.Media, userIDs)
|
||||
collectTodoMediaUserRefs(msg.Media, userIDs)
|
||||
if msg.Reactions != nil {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue