feat: sync bot keyboards and callbacks

Sync telesrv b96f2dd (feat(bot): complete keyboards callbacks and durable delivery).

Skipped private docs and preserved public README files per sync rules; normalized the appearance seed log label for public naming.
This commit is contained in:
A 2026-07-19 20:38:48 +08:00
parent 0c99ae0a9d
commit bf965f610c
80 changed files with 7212 additions and 349 deletions

View file

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

View file

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

View file

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

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

View file

@ -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.gameP3 不支持:返回空答案(客户端不弹任何东西),
// 不挂起、不推送(避免给 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仅在线推给
// botbot 离线则投递 0但仍走超时窗口I5给 bot 上线追答机会)。
// MsgID 透传请求者侧的 box idP3 不做 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 idchannel 使用共享 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.botUserIDI6
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
}

View file

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

View file

@ -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 把答案投递到一个等待者已离开的 chTOCTOU
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
// 必须等于注册时的 botUserIDI6。返回是否成功投递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
}

View file

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

View file

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

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

View file

@ -112,7 +112,7 @@ func tgMessage(m domain.Message) tg.MessageClass {
msg.SetInvertMedia(true)
}
}
// reply_markupbot inline keyboard仅普通 tg.Message 携带service 消息不带)。
// reply_markupbot 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

View file

@ -821,6 +821,7 @@ type Deps struct {
Updates UpdatesService
BootstrapUpdates store.BootstrapUpdateJobStore
BotAPIUpdates store.BotAPIUpdateStore
BotCallbacks store.BotCallbackRegistryStore
Contacts ContactsService
Dialogs DialogsService
Chatlists ChatlistsService

View file

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

View file

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

View file

@ -169,15 +169,20 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
sendErr = err
return nil, sendErr
}
// reply_markupbot inline keyboard仅 bot 账号发送被接受+校验;非 bot 静默丢弃。
// reply_markupbot 可发送 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_messageLayer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。
// Phase 1 仅认 inputRichMessageblocks 形态HTML/Markdown 变体返回错误。

View file

@ -93,7 +93,7 @@ type Config struct {
// Router 把解密后的 RPC 请求按 semantic method 路由到 typed handlertlprofile.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)

View file

@ -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_markupbot inline keyboard on media仅 bot 接受+校验,非 bot 静默丢弃。
// reply_markupbot 可发送 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{

View file

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