feat: sync Bot API gateway support
This commit is contained in:
parent
9a501f900a
commit
4c0cc2b7a7
44 changed files with 4609 additions and 49 deletions
82
internal/rpc/botapi_enqueue_dispatcher.go
Normal file
82
internal/rpc/botapi_enqueue_dispatcher.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Bot API 私聊 enqueue 异步化(性能审计 H2):user→bot 私聊发送/编辑时,把
|
||||
// bot_api_updates 的 INSERT(以及可能打 PG 的 bot 判定)移出发送者 RPC 同步路径,
|
||||
// 发送者不再为 Bot API 队列写入多等一次 PG 往返。
|
||||
//
|
||||
// 与 channel fanout dispatcher 的关键差异:bot_api_updates 行本身就是投递真值
|
||||
// (getUpdates 只读该表,没有 getDifference 类兜底),因此队列满时**同步回退执行**
|
||||
// 而不是丢弃——发送者多等一次 INSERT,换 update 不丢。
|
||||
//
|
||||
// 单 worker FIFO:保证同一 bot 的 update_id 顺序与发送顺序一致(并发 goroutine 池
|
||||
// 会让 bot 侧 getUpdates 看到乱序消息)。
|
||||
|
||||
const (
|
||||
defaultBotAPIEnqueueBuffer = 4096
|
||||
botAPIEnqueueJobTimeout = 10 * time.Second
|
||||
botAPIEnqueueFallbackReason = "bot api enqueue queue full, falling back to synchronous insert"
|
||||
)
|
||||
|
||||
type botAPIEnqueueDispatcher struct {
|
||||
log *zap.Logger
|
||||
jobs chan func(context.Context)
|
||||
started atomic.Bool
|
||||
}
|
||||
|
||||
func newBotAPIEnqueueDispatcher(log *zap.Logger, buffer int) *botAPIEnqueueDispatcher {
|
||||
if buffer <= 0 {
|
||||
buffer = defaultBotAPIEnqueueBuffer
|
||||
}
|
||||
return &botAPIEnqueueDispatcher{
|
||||
log: log.Named("botapi-enqueue"),
|
||||
jobs: make(chan func(context.Context), buffer),
|
||||
}
|
||||
}
|
||||
|
||||
// RunBotAPIEnqueue 启动 Bot API enqueue 后台 worker,由 main 与其它 dispatcher 一同 go 起。
|
||||
// 阻塞到 ctx 取消;未调用前 enqueue 同步执行(行为同旧版,测试/未装配场景不变)。
|
||||
func (r *Router) RunBotAPIEnqueue(ctx context.Context) {
|
||||
r.botAPIEnqueueQueue.Run(ctx)
|
||||
}
|
||||
|
||||
func (d *botAPIEnqueueDispatcher) Run(ctx context.Context) {
|
||||
if d == nil || !d.started.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case job := <-d.jobs:
|
||||
jobCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), botAPIEnqueueJobTimeout)
|
||||
job(jobCtx)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue 投递一个 Bot API 队列写入任务。dispatcher 未启动时同步执行(用请求 ctx);
|
||||
// 已启动时投入 FIFO,满则同步回退执行——绝不丢弃(队列行是投递真值)。
|
||||
func (d *botAPIEnqueueDispatcher) Enqueue(reqCtx context.Context, job func(context.Context)) {
|
||||
if d == nil || job == nil {
|
||||
return
|
||||
}
|
||||
if !d.started.Load() {
|
||||
job(reqCtx)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case d.jobs <- job:
|
||||
default:
|
||||
d.log.Warn(botAPIEnqueueFallbackReason)
|
||||
job(reqCtx)
|
||||
}
|
||||
}
|
||||
76
internal/rpc/botapi_enqueue_dispatcher_test.go
Normal file
76
internal/rpc/botapi_enqueue_dispatcher_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
|
||||
// TestBotAPIEnqueueDispatcherSynchronousBeforeRun 锁定未启动时的同步回退:
|
||||
// 测试/未装配场景下 enqueue 行为与旧版完全一致(job 在调用方 goroutine 内即时执行)。
|
||||
func TestBotAPIEnqueueDispatcherSynchronousBeforeRun(t *testing.T) {
|
||||
d := newBotAPIEnqueueDispatcher(zaptest.NewLogger(t), 4)
|
||||
ran := false
|
||||
d.Enqueue(context.Background(), func(context.Context) { ran = true })
|
||||
if !ran {
|
||||
t.Fatal("job must run synchronously before Run is called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotAPIEnqueueDispatcherFIFOOrder 锁定启动后单 worker FIFO:同一 bot 的
|
||||
// update_id 顺序必须与 enqueue 顺序一致(乱序会让 bot 侧 getUpdates 看到错序消息)。
|
||||
func TestBotAPIEnqueueDispatcherFIFOOrder(t *testing.T) {
|
||||
d := newBotAPIEnqueueDispatcher(zaptest.NewLogger(t), 16)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go d.Run(ctx)
|
||||
for !d.started.Load() {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var order []int
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < 5; i++ {
|
||||
i := i
|
||||
d.Enqueue(context.Background(), func(context.Context) {
|
||||
mu.Lock()
|
||||
order = append(order, i)
|
||||
if len(order) == 5 {
|
||||
close(done)
|
||||
}
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("jobs did not complete")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for i, got := range order {
|
||||
if got != i {
|
||||
t.Fatalf("order = %v, want FIFO", order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotAPIEnqueueDispatcherFallsBackWhenFull 锁定队列满时的同步回退:Bot API 队列行
|
||||
// 是投递真值(无 getDifference 类兜底),满时发送者多等一次 INSERT,绝不丢。
|
||||
func TestBotAPIEnqueueDispatcherFallsBackWhenFull(t *testing.T) {
|
||||
d := newBotAPIEnqueueDispatcher(zaptest.NewLogger(t), 1)
|
||||
d.started.Store(true) // 模拟已启动但 worker 不消费(阻塞场景)
|
||||
|
||||
// 塞满容量 1 的队列。
|
||||
d.Enqueue(context.Background(), func(context.Context) {})
|
||||
|
||||
ran := false
|
||||
d.Enqueue(context.Background(), func(context.Context) { ran = true })
|
||||
if !ran {
|
||||
t.Fatal("job must fall back to synchronous execution when queue is full")
|
||||
}
|
||||
}
|
||||
469
internal/rpc/botapi_gateway.go
Normal file
469
internal/rpc/botapi_gateway.go
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var botAPIAuthKeyID = [8]byte{'B', 'O', 'T', 'A', 'P', 'I', 0, 1}
|
||||
|
||||
const botAPIChannelChatIDBase int64 = 1000000000000
|
||||
|
||||
// BotAPISelf returns the authenticated bot as a domain user.
|
||||
func (r *Router) BotAPISelf(ctx context.Context, botID int64) (domain.User, error) {
|
||||
if r == nil || r.deps.Users == nil || botID == 0 {
|
||||
return domain.User{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
u, found, err := r.deps.Users.ByID(ctx, botID, botID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found || !u.Bot {
|
||||
return domain.User{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// BotAPIUpdates returns durable update_id based events projected for the HTTP
|
||||
// Bot API. New deployments use the dedicated Bot API queue; the legacy
|
||||
// user_update_events fallback is kept for tests that have not wired the queue.
|
||||
func (r *Router) BotAPIUpdates(ctx context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error) {
|
||||
if r == nil || botID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if r.deps.BotAPIUpdates != nil {
|
||||
return r.botAPIQueuedUpdates(ctx, botID, offset)
|
||||
}
|
||||
if r.deps.Updates == nil {
|
||||
return nil, nil
|
||||
}
|
||||
fromPts := 0
|
||||
if offset > 0 {
|
||||
fromPts = int(offset - 1)
|
||||
} else if st, found, err := r.deps.Updates.ConfirmedState(ctx, botAPIAuthKeyID, botID); err != nil {
|
||||
return nil, err
|
||||
} else if found {
|
||||
fromPts = st.Pts
|
||||
}
|
||||
diff, err := r.deps.Updates.GetDifference(ctx, botAPIAuthKeyID, botID, domain.UpdateState{Pts: fromPts})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(diff.Events) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return r.enrichUpdateEvents(ctx, botID, diff.Events), nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error) {
|
||||
if r == nil || botID == 0 {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(chatID)
|
||||
if !ok {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if text == "" {
|
||||
return domain.Message{}, errors.New("MESSAGE_EMPTY")
|
||||
}
|
||||
if utf8.RuneCountInString(text) > domain.MaxMessageTextLength {
|
||||
return domain.Message{}, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
var reply *domain.MessageReply
|
||||
if replyToMessageID > 0 {
|
||||
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
return r.botAPISendChannelMessage(ctx, botID, peer.ID, text, entities, nil, replyMarkup, silent, reply)
|
||||
}
|
||||
if r.deps.Messages == nil {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
if r.deps.Users != nil && peer.ID != botID {
|
||||
if _, found, err := r.deps.Users.ByID(ctx, botID, peer.ID); err != nil {
|
||||
return domain.Message{}, err
|
||||
} else if !found {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
}
|
||||
res, err := r.deps.Messages.SendPrivateText(ctx, botID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: botID,
|
||||
RecipientUserID: peer.ID,
|
||||
RandomID: randomNonZeroInt64(),
|
||||
Message: text,
|
||||
Entities: append([]domain.MessageEntity(nil), entities...),
|
||||
Silent: silent,
|
||||
ReplyTo: reply,
|
||||
Date: int(time.Now().Unix()),
|
||||
ReplyMarkup: replyMarkup,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
return res.SenderMessage, nil
|
||||
}
|
||||
|
||||
// BotAPISendMedia sends a photo/document message through the same files service
|
||||
// and private/channel message state machines used by MTProto sendMedia.
|
||||
func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error) {
|
||||
if r == nil || r.deps.Files == nil || botID == 0 {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
peer, ok := botAPIPeerFromChatID(chatID)
|
||||
if !ok {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if utf8.RuneCountInString(caption) > domain.MaxMessageTextLength {
|
||||
return domain.Message{}, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
media, err := r.botAPIMedia(ctx, botID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
var reply *domain.MessageReply
|
||||
if replyToMessageID > 0 {
|
||||
reply = &domain.MessageReply{Peer: peer, MessageID: replyToMessageID}
|
||||
}
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
return r.botAPISendChannelMessage(ctx, botID, peer.ID, caption, entities, media, replyMarkup, silent, reply)
|
||||
}
|
||||
if r.deps.Messages == nil {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
if r.deps.Users != nil && peer.ID != botID {
|
||||
if _, found, err := r.deps.Users.ByID(ctx, botID, peer.ID); err != nil {
|
||||
return domain.Message{}, err
|
||||
} else if !found {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
}
|
||||
res, err := r.deps.Messages.SendPrivateText(ctx, botID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: botID,
|
||||
RecipientUserID: peer.ID,
|
||||
RandomID: randomNonZeroInt64(),
|
||||
Message: caption,
|
||||
Entities: append([]domain.MessageEntity(nil), entities...),
|
||||
Media: media,
|
||||
Silent: silent,
|
||||
ReplyTo: reply,
|
||||
Date: int(time.Now().Unix()),
|
||||
ReplyMarkup: replyMarkup,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
return res.SenderMessage, nil
|
||||
}
|
||||
|
||||
func botAPIPeerFromChatID(chatID int64) (domain.Peer, bool) {
|
||||
switch {
|
||||
case chatID > 0:
|
||||
return domain.Peer{Type: domain.PeerTypeUser, ID: chatID}, true
|
||||
case chatID < -botAPIChannelChatIDBase:
|
||||
channelID := -botAPIChannelChatIDBase - chatID
|
||||
if channelID > 0 {
|
||||
return domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, true
|
||||
}
|
||||
}
|
||||
return domain.Peer{}, false
|
||||
}
|
||||
|
||||
func (r *Router) botAPISendChannelMessage(ctx context.Context, botID, channelID int64, text string, entities []domain.MessageEntity, media *domain.MessageMedia, replyMarkup *domain.MessageReplyMarkup, silent bool, reply *domain.MessageReply) (domain.Message, error) {
|
||||
if r.deps.Channels == nil {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
mentionUserIDs := r.mentionUserIDsFromDomain(ctx, botID, text, entities)
|
||||
res, err := r.deps.Channels.SendMessage(ctx, botID, domain.SendChannelMessageRequest{
|
||||
UserID: botID,
|
||||
ChannelID: channelID,
|
||||
RandomID: randomNonZeroInt64(),
|
||||
Message: text,
|
||||
Entities: append([]domain.MessageEntity(nil), entities...),
|
||||
Media: media,
|
||||
MentionUserIDs: mentionUserIDs,
|
||||
SkipRecipientLookup: true,
|
||||
PostAuthor: r.channelPostAuthorName(ctx, botID),
|
||||
Silent: silent,
|
||||
ReplyTo: reply,
|
||||
ReplyMarkup: replyMarkup,
|
||||
Date: int(time.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, botAPIChannelSendErr(err)
|
||||
}
|
||||
if !res.Duplicate {
|
||||
r.enqueueChannelMessageFanout(ctx, botID, res, nil)
|
||||
r.pushChannelDiscussionUpdate(ctx, botID, res.Discussion)
|
||||
r.maybeEnqueueWebPageResolve(botID, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, res.Message.ID, res.Message.Media)
|
||||
}
|
||||
return botAPIMessageFromChannel(botID, res.Message), nil
|
||||
}
|
||||
|
||||
func botAPIMessageFromChannel(botID int64, msg domain.ChannelMessage) domain.Message {
|
||||
from := msg.From
|
||||
if from.Type == "" && msg.SenderUserID != 0 {
|
||||
from = domain.Peer{Type: domain.PeerTypeUser, ID: msg.SenderUserID}
|
||||
}
|
||||
return domain.Message{
|
||||
ID: msg.ID,
|
||||
RandomID: msg.RandomID,
|
||||
OwnerUserID: botID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: msg.ChannelID},
|
||||
From: from,
|
||||
Date: msg.Date,
|
||||
EditDate: msg.EditDate,
|
||||
Out: msg.SenderUserID == botID,
|
||||
Silent: msg.Silent,
|
||||
NoForwards: msg.NoForwards,
|
||||
Body: msg.Body,
|
||||
Entities: append([]domain.MessageEntity(nil), msg.Entities...),
|
||||
ReplyTo: msg.ReplyTo,
|
||||
Forward: msg.Forward,
|
||||
Reactions: msg.Reactions,
|
||||
Pts: msg.Pts,
|
||||
TTLPeriod: msg.TTLPeriod,
|
||||
ExpiresAt: msg.ExpiresAt,
|
||||
Media: msg.Media,
|
||||
MediaUnread: msg.MediaUnread,
|
||||
ViaBotID: msg.ViaBotID,
|
||||
GroupedID: msg.GroupedID,
|
||||
ReplyMarkup: msg.ReplyMarkup,
|
||||
RichMessage: msg.RichMessage,
|
||||
Pinned: msg.Pinned,
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIChannelSendErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrChannelInvalid),
|
||||
errors.Is(err, domain.ErrChannelPrivate),
|
||||
errors.Is(err, domain.ErrChannelUserBanned):
|
||||
return errors.New("CHAT_ID_INVALID")
|
||||
case errors.Is(err, domain.ErrChannelWriteForbidden):
|
||||
return errors.New("CHAT_WRITE_FORBIDDEN")
|
||||
case errors.Is(err, domain.ErrChannelAdminRequired):
|
||||
return errors.New("CHAT_ADMIN_REQUIRED")
|
||||
case errors.Is(err, domain.ErrReplyMessageIDInvalid):
|
||||
return errors.New("REPLY_MESSAGE_ID_INVALID")
|
||||
default:
|
||||
return channelInvalidErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) botAPIMedia(ctx context.Context, botID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte) (*domain.MessageMedia, error) {
|
||||
switch kind {
|
||||
case "photo":
|
||||
var photo domain.Photo
|
||||
var err error
|
||||
switch {
|
||||
case len(fileBytes) > 0:
|
||||
photo, err = r.deps.Files.CreatePhotoFromBytes(ctx, fileBytes)
|
||||
case remoteURL != "":
|
||||
photo, err = r.deps.Files.CreatePhotoFromURL(ctx, remoteURL)
|
||||
case locationKey != "":
|
||||
id, ok := botAPIPhotoID(locationKey)
|
||||
if !ok {
|
||||
return nil, errors.New("FILE_ID_INVALID")
|
||||
}
|
||||
var found bool
|
||||
photo, found, err = r.deps.Files.GetPhoto(ctx, id)
|
||||
if err == nil && !found {
|
||||
err = errors.New("FILE_ID_INVALID")
|
||||
}
|
||||
default:
|
||||
err = errors.New("FILE_ID_INVALID")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, botAPIMediaErr(err)
|
||||
}
|
||||
return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo}, nil
|
||||
case "document":
|
||||
var doc domain.Document
|
||||
var err error
|
||||
switch {
|
||||
case len(fileBytes) > 0:
|
||||
doc, err = r.deps.Files.CreateDocumentFromBytes(ctx, fileBytes, domain.DocumentSpec{
|
||||
MimeType: mimeType,
|
||||
Attributes: botAPIDocumentAttributes(fileName),
|
||||
ForceFile: true,
|
||||
})
|
||||
case remoteURL != "":
|
||||
doc, err = r.deps.Files.CreateDocumentFromURL(ctx, remoteURL)
|
||||
case locationKey != "":
|
||||
id, ok := botAPIDocumentID(locationKey)
|
||||
if !ok {
|
||||
return nil, errors.New("FILE_ID_INVALID")
|
||||
}
|
||||
var found bool
|
||||
doc, found, err = r.deps.Files.GetDocument(ctx, id)
|
||||
if err == nil && !found {
|
||||
err = errors.New("FILE_ID_INVALID")
|
||||
}
|
||||
default:
|
||||
err = errors.New("FILE_ID_INVALID")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, botAPIMediaErr(err)
|
||||
}
|
||||
return &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &doc}, nil
|
||||
default:
|
||||
return nil, errors.New("MEDIA_INVALID")
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIPhotoID(locationKey string) (int64, bool) {
|
||||
if !strings.HasPrefix(locationKey, "photo:") {
|
||||
return 0, false
|
||||
}
|
||||
rest := strings.TrimPrefix(locationKey, "photo:")
|
||||
idText, _, ok := strings.Cut(rest, ":")
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
id, err := strconv.ParseInt(idText, 10, 64)
|
||||
return id, err == nil && id > 0
|
||||
}
|
||||
|
||||
func botAPIDocumentID(locationKey string) (int64, bool) {
|
||||
if !strings.HasPrefix(locationKey, "doc:") {
|
||||
return 0, false
|
||||
}
|
||||
rest := strings.TrimPrefix(locationKey, "doc:")
|
||||
idText, _, _ := strings.Cut(rest, ":")
|
||||
id, err := strconv.ParseInt(idText, 10, 64)
|
||||
return id, err == nil && id > 0
|
||||
}
|
||||
|
||||
func botAPIDocumentAttributes(fileName string) []domain.DocumentAttribute {
|
||||
fileName = strings.TrimSpace(fileName)
|
||||
if fileName == "" {
|
||||
return nil
|
||||
}
|
||||
return []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: fileName}}
|
||||
}
|
||||
|
||||
func botAPIMediaErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(err.Error()), "FILE_ID_INVALID") {
|
||||
return err
|
||||
}
|
||||
return errors.New("MEDIA_INVALID")
|
||||
}
|
||||
|
||||
// BotAPIEditMessageText edits a bot-owned private text message through the
|
||||
// normal durable edit state machine. Positive chat_id is a private user chat.
|
||||
func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error) {
|
||||
if r == nil || r.deps.Messages == nil || botID == 0 {
|
||||
return domain.Message{}, errors.New("BOT_INVALID")
|
||||
}
|
||||
if chatID <= 0 {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if messageID <= 0 || messageID > domain.MaxMessageBoxID {
|
||||
return domain.Message{}, errors.New("MESSAGE_ID_INVALID")
|
||||
}
|
||||
if text == "" {
|
||||
return domain.Message{}, errors.New("MESSAGE_EMPTY")
|
||||
}
|
||||
if utf8.RuneCountInString(text) > domain.MaxMessageTextLength {
|
||||
return domain.Message{}, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: chatID}
|
||||
res, err := r.deps.Messages.EditMessage(ctx, botID, domain.EditMessageRequest{
|
||||
OwnerUserID: botID,
|
||||
Peer: peer,
|
||||
ID: messageID,
|
||||
Message: text,
|
||||
Entities: append([]domain.MessageEntity(nil), entities...),
|
||||
EditDate: int(time.Now().Unix()),
|
||||
SetReplyMarkup: setReplyMarkup,
|
||||
ReplyMarkup: replyMarkup,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
self := res.Self()
|
||||
if self.Message.ID == 0 {
|
||||
return domain.Message{}, errors.New("MESSAGE_ID_INVALID")
|
||||
}
|
||||
return self.Message, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if r == nil || r.deps.Messages == nil || botID == 0 {
|
||||
return false, errors.New("BOT_INVALID")
|
||||
}
|
||||
if chatID <= 0 {
|
||||
return false, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if messageID <= 0 || messageID > domain.MaxMessageBoxID {
|
||||
return false, errors.New("MESSAGE_ID_INVALID")
|
||||
}
|
||||
_, err := r.deps.Messages.DeleteMessages(ctx, botID, domain.DeleteMessagesRequest{
|
||||
OwnerUserID: botID,
|
||||
IDs: []int{messageID},
|
||||
Revoke: true,
|
||||
Date: int(time.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// BotAPIAnswerCallbackQuery bridges Bot API answerCallbackQuery to the same
|
||||
// process-local callback registry used by messages.setBotCallbackAnswer.
|
||||
func (r *Router) BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, callbackQueryID, text, url string, showAlert bool, cacheTime int) (bool, error) {
|
||||
if r == nil || r.callbacks == nil || botID == 0 {
|
||||
return false, errors.New("BOT_INVALID")
|
||||
}
|
||||
queryID, err := strconv.ParseInt(callbackQueryID, 10, 64)
|
||||
if err != nil || queryID == 0 {
|
||||
return false, errors.New("QUERY_ID_INVALID")
|
||||
}
|
||||
if utf8.RuneCountInString(text) > domain.MaxBotCallbackAnswerLen {
|
||||
return false, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
if cacheTime < 0 {
|
||||
cacheTime = 0
|
||||
}
|
||||
r.callbacks.resolve(botID, queryID, domain.BotCallbackAnswer{
|
||||
Alert: showAlert,
|
||||
Message: text,
|
||||
URL: url,
|
||||
CacheTime: cacheTime,
|
||||
})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// BotAPIGetFile exposes the existing upload.getFile blob location space to the
|
||||
// HTTP file endpoint after the bot token has authenticated the request.
|
||||
func (r *Router) BotAPIGetFile(ctx context.Context, botID int64, locationKey string, offset int64, limit int) (domain.FileChunk, bool, error) {
|
||||
if r == nil || r.deps.Files == nil || botID == 0 {
|
||||
return domain.FileChunk{}, false, nil
|
||||
}
|
||||
if limit <= 0 || limit > maxUploadGetFileChunkLimit {
|
||||
limit = maxUploadGetFileChunkLimit
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return r.deps.Files.GetFile(ctx, domain.FileDownloadRequest{
|
||||
LocationKey: locationKey,
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
||||
387
internal/rpc/botapi_gateway_test.go
Normal file
387
internal/rpc/botapi_gateway_test.go
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/clock"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appbots "telesrv/internal/app/bots"
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appmessages "telesrv/internal/app/messages"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1001, Phone: "15550008001", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
bot, err := userStore.Create(ctx, domain.User{AccessHash: 2001, Phone: "15550008002", FirstName: "TetrisBot", Username: "TetrisBot", Bot: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create bot: %v", err)
|
||||
}
|
||||
|
||||
channelStore := memory.NewChannelStore()
|
||||
channelService := appchannels.NewService(channelStore)
|
||||
created, err := channelService.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{
|
||||
Title: "Group1",
|
||||
MemberUserIDs: []int64{bot.ID},
|
||||
Date: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create megagroup: %v", err)
|
||||
}
|
||||
sessions := &captureSessions{
|
||||
channelMembers: map[int64][]int64{created.Channel.ID: {owner.ID}},
|
||||
}
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: channelService,
|
||||
Sessions: sessions,
|
||||
}, 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)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPISendMessage: %v", err)
|
||||
}
|
||||
if msg.Peer.Type != domain.PeerTypeChannel || msg.Peer.ID != created.Channel.ID {
|
||||
t.Fatalf("msg peer = %+v, want channel %d", msg.Peer, created.Channel.ID)
|
||||
}
|
||||
if msg.From.Type != domain.PeerTypeUser || msg.From.ID != bot.ID || !msg.Out {
|
||||
t.Fatalf("msg from/out = %+v out=%v, want bot outbound", msg.From, msg.Out)
|
||||
}
|
||||
if msg.Body != "hello Group1 from bot api" || msg.ID == 0 || msg.Pts == 0 {
|
||||
t.Fatalf("msg = %+v, want durable channel message with id and pts", msg)
|
||||
}
|
||||
|
||||
history, err := channelService.GetHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10})
|
||||
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 {
|
||||
t.Fatalf("history messages = %+v, want bot channel message", history.Messages)
|
||||
}
|
||||
if pushed := sessions.pushedUserIDs(); !fanoutHasID(pushed, owner.ID) {
|
||||
t.Fatalf("fanout pushed = %v, want owner %d to receive online channel update", pushed, owner.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPISendMessageRejectsUnsupportedNegativeChatID(t *testing.T) {
|
||||
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
_, err := r.BotAPISendMessage(context.Background(), 1234, -42, "hello", nil, nil, false, false, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "CHAT_ID_INVALID") {
|
||||
t.Fatalf("BotAPISendMessage err = %v, want CHAT_ID_INVALID", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPISendMessageMissingSupergroupReturnsChatIDInvalid(t *testing.T) {
|
||||
channelService := appchannels.NewService(memory.NewChannelStore())
|
||||
r := New(Config{}, Deps{Channels: channelService}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
chatID := -botAPIChannelChatIDBase - 9999
|
||||
_, err := r.BotAPISendMessage(context.Background(), 1234, chatID, "hello", nil, nil, false, false, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "CHAT_ID_INVALID") {
|
||||
t.Fatalf("BotAPISendMessage err = %v, want CHAT_ID_INVALID", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIGetUpdatesReceivesVisibleSupergroupMessage(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
res, err := fixture.channels.SendMessage(fixture.ctx, fixture.owner.ID, domain.SendChannelMessageRequest{
|
||||
UserID: fixture.owner.ID,
|
||||
ChannelID: fixture.channel.ID,
|
||||
RandomID: 1001,
|
||||
Message: "/ping from group",
|
||||
SkipRecipientLookup: true,
|
||||
Date: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
fixture.router.enqueueChannelMessageFanout(fixture.ctx, fixture.owner.ID, res, nil)
|
||||
|
||||
events, err := fixture.router.BotAPIUpdates(fixture.ctx, fixture.bot.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIUpdates: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("events = %+v, want one bot api update", events)
|
||||
}
|
||||
event := events[0]
|
||||
if event.Type != domain.UpdateEventNewMessage || event.Pts <= 0 {
|
||||
t.Fatalf("event = %+v, want new_message with update_id", event)
|
||||
}
|
||||
if event.Message.Peer.Type != domain.PeerTypeChannel || event.Message.Peer.ID != fixture.channel.ID {
|
||||
t.Fatalf("message peer = %+v, want channel %d", event.Message.Peer, fixture.channel.ID)
|
||||
}
|
||||
if event.Message.From.Type != domain.PeerTypeUser || event.Message.From.ID != fixture.owner.ID || event.Message.Body != "/ping from group" || event.Message.Out {
|
||||
t.Fatalf("message = %+v, want incoming owner command", event.Message)
|
||||
}
|
||||
|
||||
next, err := fixture.router.BotAPIUpdates(fixture.ctx, fixture.bot.ID, int64(event.Pts)+1)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIUpdates confirm: %v", err)
|
||||
}
|
||||
if len(next) != 0 {
|
||||
t.Fatalf("next events = %+v, want empty after offset confirm", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIGetUpdatesSkipsHiddenPrivacySupergroupMessage(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
res, err := fixture.channels.SendMessage(fixture.ctx, fixture.owner.ID, domain.SendChannelMessageRequest{
|
||||
UserID: fixture.owner.ID,
|
||||
ChannelID: fixture.channel.ID,
|
||||
RandomID: 1002,
|
||||
Message: "plain group chatter",
|
||||
SkipRecipientLookup: true,
|
||||
Date: 101,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
if len(res.SkipDeliveryUserIDs) == 0 {
|
||||
t.Fatalf("SkipDeliveryUserIDs empty, want privacy bot excluded")
|
||||
}
|
||||
fixture.router.enqueueChannelMessageFanout(fixture.ctx, fixture.owner.ID, res, nil)
|
||||
|
||||
events, err := fixture.router.BotAPIUpdates(fixture.ctx, fixture.bot.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIUpdates: %v", err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("events = %+v, want hidden privacy message excluded", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIGetUpdatesReceivesPrivateBotMessage(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
res, err := fixture.messages.SendPrivateText(fixture.ctx, fixture.owner.ID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: fixture.owner.ID,
|
||||
RecipientUserID: fixture.bot.ID,
|
||||
RandomID: 2001,
|
||||
Message: "private hello",
|
||||
Date: 102,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
fixture.router.enqueueBotAPIPrivateMessageUpdate(fixture.ctx, res)
|
||||
|
||||
events, err := fixture.router.BotAPIUpdates(fixture.ctx, fixture.bot.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIUpdates: %v", err)
|
||||
}
|
||||
if len(events) != 1 || events[0].Message.Peer.Type != domain.PeerTypeUser || events[0].Message.Peer.ID != fixture.owner.ID || events[0].Message.Body != "private hello" {
|
||||
t.Fatalf("events = %+v, want private incoming message", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIGetUpdatesBatchesPrivateMessageProjection(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
counting := &countingBotAPIMessagesService{Service: fixture.messages}
|
||||
fixture.router.deps.Messages = counting
|
||||
for i, text := range []string{"private one", "private two"} {
|
||||
res, err := fixture.messages.SendPrivateText(fixture.ctx, fixture.owner.ID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: fixture.owner.ID,
|
||||
RecipientUserID: fixture.bot.ID,
|
||||
RandomID: int64(2100 + i),
|
||||
Message: text,
|
||||
Date: 120 + i,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText %d: %v", i, err)
|
||||
}
|
||||
fixture.router.enqueueBotAPIPrivateMessageUpdate(fixture.ctx, res)
|
||||
}
|
||||
|
||||
events, err := fixture.router.BotAPIUpdates(fixture.ctx, fixture.bot.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIUpdates: %v", err)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("events = %+v, want two private updates", events)
|
||||
}
|
||||
if counting.getMessagesCalls != 1 {
|
||||
t.Fatalf("private GetMessages calls = %d, want 1 batched projection", counting.getMessagesCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIUpdateWaiterWakesOnPrivateEnqueue(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
version := fixture.router.BotAPIUpdateWaitVersion(fixture.bot.ID)
|
||||
woke := make(chan bool, 1)
|
||||
go func() {
|
||||
woke <- fixture.router.WaitBotAPIUpdate(fixture.ctx, fixture.bot.ID, version, time.Second)
|
||||
}()
|
||||
res, err := fixture.messages.SendPrivateText(fixture.ctx, fixture.owner.ID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: fixture.owner.ID,
|
||||
RecipientUserID: fixture.bot.ID,
|
||||
RandomID: 2201,
|
||||
Message: "wake bot api polling",
|
||||
Date: 121,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
fixture.router.enqueueBotAPIPrivateMessageUpdate(fixture.ctx, res)
|
||||
select {
|
||||
case ok := <-woke:
|
||||
if !ok {
|
||||
t.Fatalf("WaitBotAPIUpdate returned false, want notify wake")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("WaitBotAPIUpdate did not wake after enqueue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIChannelBatchEnqueueLoadsBotCandidatesOnce(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
counting := &countingBotCandidateChannelsService{Service: fixture.channels}
|
||||
fixture.router.deps.Channels = counting
|
||||
first, err := fixture.channels.SendMessage(fixture.ctx, fixture.owner.ID, domain.SendChannelMessageRequest{
|
||||
UserID: fixture.owner.ID,
|
||||
ChannelID: fixture.channel.ID,
|
||||
RandomID: 3001,
|
||||
Message: "/first batch command",
|
||||
SkipRecipientLookup: true,
|
||||
Date: 103,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage first: %v", err)
|
||||
}
|
||||
second, err := fixture.channels.SendMessage(fixture.ctx, fixture.owner.ID, domain.SendChannelMessageRequest{
|
||||
UserID: fixture.owner.ID,
|
||||
ChannelID: fixture.channel.ID,
|
||||
RandomID: 3002,
|
||||
Message: "/second batch command",
|
||||
SkipRecipientLookup: true,
|
||||
Date: 104,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage second: %v", err)
|
||||
}
|
||||
|
||||
fixture.router.enqueueBotAPIChannelMessagesUpdate(fixture.ctx, fixture.owner.ID, []domain.SendChannelMessageResult{first, second})
|
||||
if counting.activeBotMemberIDsCalls != 1 {
|
||||
t.Fatalf("ActiveBotMemberIDs calls = %d, want 1 for same-channel batch", counting.activeBotMemberIDsCalls)
|
||||
}
|
||||
if counting.activeMemberIDsCalls != 0 {
|
||||
t.Fatalf("ActiveMemberIDs calls = %d, want 0 on Bot API enqueue path", counting.activeMemberIDsCalls)
|
||||
}
|
||||
if counting.getMessagesCalls != 0 {
|
||||
t.Fatalf("channel GetMessages calls during enqueue = %d, want 0 on ordinary send path", counting.getMessagesCalls)
|
||||
}
|
||||
events, err := fixture.router.BotAPIUpdates(fixture.ctx, fixture.bot.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIUpdates: %v", err)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("events = %+v, want two bot api updates", events)
|
||||
}
|
||||
if counting.getMessagesCalls != 1 {
|
||||
t.Fatalf("channel GetMessages calls after getUpdates = %d, want 1 batched projection", counting.getMessagesCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type countingBotCandidateChannelsService struct {
|
||||
*appchannels.Service
|
||||
activeBotMemberIDsCalls int
|
||||
activeMemberIDsCalls int
|
||||
getMessagesCalls int
|
||||
}
|
||||
|
||||
func (s *countingBotCandidateChannelsService) ActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
|
||||
s.activeBotMemberIDsCalls++
|
||||
return s.Service.ActiveBotMemberIDs(ctx, viewerUserID, channelID, limit)
|
||||
}
|
||||
|
||||
func (s *countingBotCandidateChannelsService) ActiveMemberIDs(ctx context.Context, userID, channelID int64, limit int) ([]int64, error) {
|
||||
s.activeMemberIDsCalls++
|
||||
return s.Service.ActiveMemberIDs(ctx, userID, channelID, limit)
|
||||
}
|
||||
|
||||
func (s *countingBotCandidateChannelsService) GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error) {
|
||||
s.getMessagesCalls++
|
||||
return s.Service.GetMessages(ctx, userID, channelID, ids)
|
||||
}
|
||||
|
||||
type countingBotAPIMessagesService struct {
|
||||
*appmessages.Service
|
||||
getMessagesCalls int
|
||||
}
|
||||
|
||||
func (s *countingBotAPIMessagesService) GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) {
|
||||
s.getMessagesCalls++
|
||||
return s.Service.GetMessages(ctx, userID, ids)
|
||||
}
|
||||
|
||||
type botAPIReceiveFixture struct {
|
||||
ctx context.Context
|
||||
owner domain.User
|
||||
bot domain.User
|
||||
channel domain.Channel
|
||||
router *Router
|
||||
channels *appchannels.Service
|
||||
messages *appmessages.Service
|
||||
}
|
||||
|
||||
func newBotAPIReceiveFixture(t *testing.T, botChatHistory bool) botAPIReceiveFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1001, Phone: "15550008101", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
dialogStore := memory.NewDialogStore()
|
||||
messageStore := memory.NewMessageStore(dialogStore)
|
||||
botStore := memory.NewBotStore(userStore)
|
||||
bot, _, err := botStore.CreateBotAccount(ctx, domain.User{AccessHash: 2001, FirstName: "TetrisBot", Username: "TetrisBot"}, domain.BotProfile{
|
||||
OwnerUserID: owner.ID,
|
||||
TokenSecret: "secret",
|
||||
ChatHistory: botChatHistory,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create bot: %v", err)
|
||||
}
|
||||
botsService := appbots.NewService(userStore, botStore, messageStore)
|
||||
channelStore := memory.NewChannelStore()
|
||||
channelsService := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(botsService))
|
||||
created, err := channelsService.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{
|
||||
Title: "Group1",
|
||||
MemberUserIDs: []int64{bot.ID},
|
||||
Date: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create megagroup: %v", err)
|
||||
}
|
||||
messagesService := appmessages.NewService(messageStore, dialogStore)
|
||||
router := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Messages: messagesService,
|
||||
Channels: channelsService,
|
||||
Bots: botsService,
|
||||
BotAPIUpdates: memory.NewBotAPIUpdateStore(),
|
||||
Sessions: &captureSessions{channelMembers: map[int64][]int64{created.Channel.ID: {owner.ID, bot.ID}}},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
return botAPIReceiveFixture{
|
||||
ctx: ctx,
|
||||
owner: owner,
|
||||
bot: bot,
|
||||
channel: created.Channel,
|
||||
router: router,
|
||||
channels: channelsService,
|
||||
messages: messagesService,
|
||||
}
|
||||
}
|
||||
111
internal/rpc/botapi_update_notifier.go
Normal file
111
internal/rpc/botapi_update_notifier.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type botAPIUpdateNotifier struct {
|
||||
mu sync.Mutex
|
||||
version map[int64]uint64
|
||||
waiters map[int64]map[chan struct{}]struct{}
|
||||
}
|
||||
|
||||
func newBotAPIUpdateNotifier() *botAPIUpdateNotifier {
|
||||
return &botAPIUpdateNotifier{
|
||||
version: make(map[int64]uint64),
|
||||
waiters: make(map[int64]map[chan struct{}]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *botAPIUpdateNotifier) current(botID int64) uint64 {
|
||||
if n == nil || botID == 0 {
|
||||
return 0
|
||||
}
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
return n.version[botID]
|
||||
}
|
||||
|
||||
func (n *botAPIUpdateNotifier) wait(ctx context.Context, botID int64, version uint64, timeout time.Duration) bool {
|
||||
if n == nil || botID == 0 || timeout <= 0 {
|
||||
return false
|
||||
}
|
||||
ch := make(chan struct{})
|
||||
n.mu.Lock()
|
||||
if n.version[botID] != version {
|
||||
n.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
waiters := n.waiters[botID]
|
||||
if waiters == nil {
|
||||
waiters = make(map[chan struct{}]struct{})
|
||||
n.waiters[botID] = waiters
|
||||
}
|
||||
waiters[ch] = struct{}{}
|
||||
n.mu.Unlock()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
defer n.remove(botID, ch)
|
||||
select {
|
||||
case <-ch:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (n *botAPIUpdateNotifier) notify(botID int64) {
|
||||
if n == nil || botID == 0 {
|
||||
return
|
||||
}
|
||||
n.mu.Lock()
|
||||
n.version[botID]++
|
||||
waiters := n.waiters[botID]
|
||||
delete(n.waiters, botID)
|
||||
n.mu.Unlock()
|
||||
for ch := range waiters {
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *botAPIUpdateNotifier) remove(botID int64, ch chan struct{}) {
|
||||
if n == nil || botID == 0 || ch == nil {
|
||||
return
|
||||
}
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
waiters := n.waiters[botID]
|
||||
if waiters == nil {
|
||||
return
|
||||
}
|
||||
delete(waiters, ch)
|
||||
if len(waiters) == 0 {
|
||||
delete(n.waiters, botID)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIUpdateWaitVersion(botID int64) uint64 {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
return r.botAPIUpdates.current(botID)
|
||||
}
|
||||
|
||||
func (r *Router) WaitBotAPIUpdate(ctx context.Context, botID int64, version uint64, timeout time.Duration) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
return r.botAPIUpdates.wait(ctx, botID, version, timeout)
|
||||
}
|
||||
|
||||
func (r *Router) notifyBotAPIUpdate(botID int64) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.botAPIUpdates.notify(botID)
|
||||
}
|
||||
416
internal/rpc/botapi_update_queue.go
Normal file
416
internal/rpc/botapi_update_queue.go
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const botAPIGetUpdatesLimit = 100
|
||||
|
||||
type botAPIChannelBotMemberProvider interface {
|
||||
ActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error)
|
||||
}
|
||||
|
||||
func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error) {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
fromID := int64(1)
|
||||
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 len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items)
|
||||
if leadingSkipped > 0 {
|
||||
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, leadingSkipped); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return r.enrichUpdateEvents(ctx, botID, events), nil
|
||||
}
|
||||
|
||||
func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate) ([]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 {
|
||||
continue
|
||||
}
|
||||
switch item.Peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if _, exists := privateSeen[item.MessageID]; !exists {
|
||||
privateSeen[item.MessageID] = struct{}{}
|
||||
privateIDs = append(privateIDs, item.MessageID)
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
seen := channelSeen[item.Peer.ID]
|
||||
if seen == nil {
|
||||
seen = make(map[int]struct{})
|
||||
channelSeen[item.Peer.ID] = seen
|
||||
}
|
||||
if _, exists := seen[item.MessageID]; !exists {
|
||||
seen[item.MessageID] = struct{}{}
|
||||
channelIDs[item.Peer.ID] = append(channelIDs[item.Peer.ID], item.MessageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
privateMessages := r.botAPIQueuedPrivateMessages(ctx, botID, privateIDs)
|
||||
channelMessages := r.botAPIQueuedChannelMessages(ctx, botID, channelIDs)
|
||||
events := make([]domain.UpdateEvent, 0, len(items))
|
||||
leadingSkipped := int64(0)
|
||||
for _, item := range items {
|
||||
event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages)
|
||||
if !ok {
|
||||
if len(events) == 0 {
|
||||
leadingSkipped = item.ID
|
||||
}
|
||||
continue
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, leadingSkipped
|
||||
}
|
||||
|
||||
func (r *Router) botAPIQueuedPrivateMessages(ctx context.Context, botID int64, ids []int) map[int]domain.Message {
|
||||
if r == nil || r.deps.Messages == nil || len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
list, err := r.deps.Messages.GetMessages(ctx, botID, ids)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[int]domain.Message, len(list.Messages))
|
||||
for _, msg := range list.Messages {
|
||||
if msg.ID <= 0 || msg.Out || !botAPIMessageProjectable(msg) {
|
||||
continue
|
||||
}
|
||||
out[msg.ID] = msg
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, idsByChannel map[int64][]int) map[int64]map[int]domain.ChannelMessage {
|
||||
if r == nil || r.deps.Channels == nil || len(idsByChannel) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[int64]map[int]domain.ChannelMessage, len(idsByChannel))
|
||||
for channelID, ids := range idsByChannel {
|
||||
if channelID == 0 || len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
history, err := r.deps.Channels.GetMessages(ctx, botID, channelID, ids)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
byID := make(map[int]domain.ChannelMessage, len(history.Messages))
|
||||
for _, msg := range history.Messages {
|
||||
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 {
|
||||
out[channelID] = byID
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.UpdateEventType, bool) {
|
||||
if item.ID <= 0 || item.BotUserID != botID || item.MessageID <= 0 {
|
||||
return "", false
|
||||
}
|
||||
eventType, ok := botAPIUpdateEventType(item.Kind)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
switch item.Peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
if item.Peer.ID <= 0 {
|
||||
return "", false
|
||||
}
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
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)
|
||||
if !ok {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
switch item.Peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
msg, found := privateMessages[item.MessageID]
|
||||
if !found {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
msg.Pts = int(item.ID)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
Date: item.Date,
|
||||
Peer: msg.Peer,
|
||||
Message: msg,
|
||||
}, true
|
||||
case domain.PeerTypeChannel:
|
||||
msg, found := channelMessages[item.Peer.ID][item.MessageID]
|
||||
if !found {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
projected := botAPIMessageFromChannel(botID, msg)
|
||||
projected.Pts = int(item.ID)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
Date: item.Date,
|
||||
Peer: projected.Peer,
|
||||
Message: projected,
|
||||
}, true
|
||||
default:
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIUpdateEventType(kind domain.BotAPIUpdateKind) (domain.UpdateEventType, bool) {
|
||||
switch kind {
|
||||
case domain.BotAPIUpdateMessage:
|
||||
return domain.UpdateEventNewMessage, true
|
||||
case domain.BotAPIUpdateEditedMessage:
|
||||
return domain.UpdateEventEditMessage, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueBotAPIPrivateMessageUpdateAsync 把私聊消息的 Bot API 队列写入投给后台
|
||||
// dispatcher(性能审计 H2):发送者 RPC 不再为 bot 判定 miss / INSERT 多等 PG 往返。
|
||||
// dispatcher 未启动(测试/未装配)时同步执行,行为不变。
|
||||
func (r *Router) enqueueBotAPIPrivateMessageUpdateAsync(ctx context.Context, res domain.SendPrivateTextResult) {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || res.Duplicate || res.RecipientMessage.ID <= 0 {
|
||||
return
|
||||
}
|
||||
r.botAPIEnqueueQueue.Enqueue(ctx, func(jobCtx context.Context) {
|
||||
r.enqueueBotAPIPrivateMessageUpdate(jobCtx, res)
|
||||
})
|
||||
}
|
||||
|
||||
// enqueueBotAPIPrivateEditUpdatesAsync 同上,覆盖私聊编辑的 edited_message 队列写入。
|
||||
func (r *Router) enqueueBotAPIPrivateEditUpdatesAsync(ctx context.Context, res domain.EditMessageResult) {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || len(res.Edited) == 0 {
|
||||
return
|
||||
}
|
||||
r.botAPIEnqueueQueue.Enqueue(ctx, func(jobCtx context.Context) {
|
||||
r.enqueueBotAPIPrivateEditUpdates(jobCtx, res)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Router) enqueueBotAPIPrivateMessageUpdate(ctx context.Context, res domain.SendPrivateTextResult) {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || res.Duplicate || res.RecipientMessage.ID <= 0 || res.RecipientMessage.Out {
|
||||
return
|
||||
}
|
||||
botID := res.RecipientMessage.OwnerUserID
|
||||
if botID == 0 || !botAPIMessageProjectable(res.RecipientMessage) {
|
||||
return
|
||||
}
|
||||
isBot, err := r.botAPIKnownBot(ctx, botID)
|
||||
if err != nil || !isBot {
|
||||
return
|
||||
}
|
||||
if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: botID,
|
||||
Kind: domain.BotAPIUpdateMessage,
|
||||
Peer: res.RecipientMessage.Peer,
|
||||
MessageID: res.RecipientMessage.ID,
|
||||
SourcePts: res.RecipientEvent.Pts,
|
||||
Date: res.RecipientMessage.Date,
|
||||
}); err == nil && created {
|
||||
r.notifyBotAPIUpdate(botID)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) enqueueBotAPIPrivateEditUpdates(ctx context.Context, res domain.EditMessageResult) {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil {
|
||||
return
|
||||
}
|
||||
for _, item := range res.Edited {
|
||||
if item.UserID == 0 || item.Message.ID <= 0 || item.Message.Out || !botAPIMessageProjectable(item.Message) {
|
||||
continue
|
||||
}
|
||||
isBot, err := r.botAPIKnownBot(ctx, item.UserID)
|
||||
if err != nil || !isBot {
|
||||
continue
|
||||
}
|
||||
if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: item.UserID,
|
||||
Kind: domain.BotAPIUpdateEditedMessage,
|
||||
Peer: item.Message.Peer,
|
||||
MessageID: item.Message.ID,
|
||||
SourcePts: item.Event.Pts,
|
||||
Date: item.Message.EditDate,
|
||||
}); err == nil && created {
|
||||
r.notifyBotAPIUpdate(item.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) enqueueBotAPIChannelMessageUpdate(ctx context.Context, originUserID int64, res domain.SendChannelMessageResult) {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || r.deps.Channels == nil || res.Duplicate || res.Message.ID <= 0 || res.Message.ChannelID == 0 {
|
||||
return
|
||||
}
|
||||
botIDs, err := r.botAPIChannelBotCandidates(ctx, originUserID, res.Message.ChannelID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r.enqueueBotAPIChannelMessageUpdateForBots(ctx, res, botIDs)
|
||||
}
|
||||
|
||||
func (r *Router) enqueueBotAPIChannelMessageUpdateForBots(ctx context.Context, res domain.SendChannelMessageResult, botIDs []int64) {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || res.Duplicate || res.Message.ID <= 0 || res.Message.ChannelID == 0 || len(botIDs) == 0 {
|
||||
return
|
||||
}
|
||||
skip := skipDeliverySet(res.SkipDeliveryUserIDs)
|
||||
for _, botID := range botIDs {
|
||||
if botID == 0 || botID == res.Message.SenderUserID {
|
||||
continue
|
||||
}
|
||||
if _, skipped := skip[botID]; skipped {
|
||||
continue
|
||||
}
|
||||
if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: botID,
|
||||
Kind: domain.BotAPIUpdateMessage,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: res.Message.ChannelID},
|
||||
MessageID: res.Message.ID,
|
||||
SourcePts: res.Event.Pts,
|
||||
Date: res.Message.Date,
|
||||
}); err == nil && created {
|
||||
r.notifyBotAPIUpdate(botID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) enqueueBotAPIChannelMessagesUpdate(ctx context.Context, originUserID int64, results []domain.SendChannelMessageResult) {
|
||||
candidates := make(map[int64][]int64)
|
||||
for _, res := range results {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || r.deps.Channels == nil || res.Duplicate || res.Message.ID <= 0 || res.Message.ChannelID == 0 {
|
||||
continue
|
||||
}
|
||||
botIDs, ok := candidates[res.Message.ChannelID]
|
||||
if !ok {
|
||||
loaded, err := r.botAPIChannelBotCandidates(ctx, originUserID, res.Message.ChannelID)
|
||||
if err != nil {
|
||||
candidates[res.Message.ChannelID] = nil
|
||||
continue
|
||||
}
|
||||
botIDs = loaded
|
||||
candidates[res.Message.ChannelID] = botIDs
|
||||
}
|
||||
r.enqueueBotAPIChannelMessageUpdateForBots(ctx, res, botIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) enqueueBotAPIChannelEditMessageUpdate(ctx context.Context, originUserID int64, res domain.EditChannelMessageResult) {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || r.deps.Channels == nil || res.Message.ID <= 0 || res.Message.ChannelID == 0 || res.Event.Pts == 0 {
|
||||
return
|
||||
}
|
||||
botIDs, err := r.botAPIChannelBotCandidates(ctx, originUserID, res.Message.ChannelID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
date := res.Message.EditDate
|
||||
if date == 0 {
|
||||
date = res.Message.Date
|
||||
}
|
||||
for _, botID := range botIDs {
|
||||
if botID == 0 || botID == res.Message.SenderUserID {
|
||||
continue
|
||||
}
|
||||
if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: botID,
|
||||
Kind: domain.BotAPIUpdateEditedMessage,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: res.Message.ChannelID},
|
||||
MessageID: res.Message.ID,
|
||||
SourcePts: res.Event.Pts,
|
||||
Date: date,
|
||||
}); err == nil && created {
|
||||
r.notifyBotAPIUpdate(botID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) botAPIChannelBotCandidates(ctx context.Context, viewerUserID, channelID int64) ([]int64, error) {
|
||||
if r == nil || r.deps.Channels == nil || channelID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
provider, ok := r.deps.Channels.(botAPIChannelBotMemberProvider)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return provider.ActiveBotMemberIDs(ctx, viewerUserID, channelID, domain.MaxSynchronousChannelDialogFanout)
|
||||
}
|
||||
|
||||
func (r *Router) botAPIKnownBot(ctx context.Context, botID int64) (bool, error) {
|
||||
if botID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if r.deps.Bots != nil {
|
||||
if _, found, err := r.deps.Bots.BotInfo(ctx, botID); err != nil || found {
|
||||
return found, err
|
||||
}
|
||||
}
|
||||
return r.userIsBot(ctx, botID), nil
|
||||
}
|
||||
|
||||
func botAPIMessageProjectable(msg domain.Message) bool {
|
||||
if msg.ID <= 0 || msg.Out {
|
||||
return false
|
||||
}
|
||||
if msg.Body != "" {
|
||||
return true
|
||||
}
|
||||
return botAPIMessageMediaProjectable(msg.Media)
|
||||
}
|
||||
|
||||
func botAPIMessageMediaProjectable(media *domain.MessageMedia) bool {
|
||||
if media.IsZero() {
|
||||
return false
|
||||
}
|
||||
switch media.Kind {
|
||||
case domain.MessageMediaKindPhoto:
|
||||
return media.Photo != nil
|
||||
case domain.MessageMediaKindDocument:
|
||||
return media.Document != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -273,6 +273,7 @@ func channelMessagesFanoutOwnerIDs(results []domain.SendChannelMessageResult, ex
|
|||
// 类事件的常见形态:发送/转发单条/讨论组联动/forum topic 消息)。语义与 enqueueChannelFanout 一致,
|
||||
// 仅多了把每 viewer 投影一次性算好预热进共享 cache(O(owner)),不改变投递/排除/nudge 行为。
|
||||
func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID int64, res domain.SendChannelMessageResult, extraUserIDs []int64) {
|
||||
r.enqueueBotAPIChannelMessageUpdate(ctx, originUserID, res)
|
||||
fanoutCache := newViewerPeerCache(r)
|
||||
ownerIDs := channelMessageFanoutOwnerIDs(res, extraUserIDs)
|
||||
skip := skipDeliverySet(res.SkipDeliveryUserIDs)
|
||||
|
|
@ -336,6 +337,7 @@ func channelEditMessageFanoutOwnerIDs(res domain.EditChannelMessageResult) []int
|
|||
// nudge 须带 channel 当前最高 pts 才能让 >cap 在线成员的 getChannelDifference 拉齐到末尾——用 Event.Pts
|
||||
// 会在 Event.Pts==0 时漏发 nudge、或低于真实 pts。max() 在三种形态(仅 Event/仅 ServiceEvent/两者)都正确。
|
||||
func (r *Router) enqueueChannelEditMessageFanout(ctx context.Context, originUserID int64, res domain.EditChannelMessageResult) {
|
||||
r.enqueueBotAPIChannelEditMessageUpdate(ctx, originUserID, res)
|
||||
fanoutCache := newViewerPeerCache(r)
|
||||
ownerIDs := channelEditMessageFanoutOwnerIDs(res)
|
||||
nudgePts := max(res.Event.Pts, res.ServiceEvent.Pts)
|
||||
|
|
@ -352,6 +354,7 @@ func (r *Router) enqueueChannelEditMessageFanout(ctx context.Context, originUser
|
|||
// 一个 Updates 内含多条 UpdateNewChannelMessage),peer refs 取全部结果并集预热。channelID/pts/
|
||||
// recipients 由调用方按批量语义给定(pts 取最后一条;recipients 受大群截断口径影响)。
|
||||
func (r *Router) enqueueChannelMessagesFanout(ctx context.Context, originUserID, channelID int64, pts int, recipients []int64, results []domain.SendChannelMessageResult, extraUserIDs []int64) {
|
||||
r.enqueueBotAPIChannelMessagesUpdate(ctx, originUserID, results)
|
||||
fanoutCache := newViewerPeerCache(r)
|
||||
ownerIDs := channelMessagesFanoutOwnerIDs(results, extraUserIDs)
|
||||
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, channelID, pts, recipients,
|
||||
|
|
|
|||
|
|
@ -328,6 +328,7 @@ type HelpService interface {
|
|||
type UpdatesService interface {
|
||||
GetState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error)
|
||||
CurrentState(ctx context.Context, userID int64) (domain.UpdateState, error)
|
||||
ConfirmedState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, bool, error)
|
||||
AcknowledgeCurrentState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error)
|
||||
GetDifference(ctx context.Context, authKeyID [8]byte, userID int64, from domain.UpdateState) (domain.UpdateDifference, error)
|
||||
ClearAuthKey(ctx context.Context, authKeyID [8]byte) error
|
||||
|
|
@ -715,6 +716,7 @@ type Deps struct {
|
|||
Users UsersService
|
||||
Updates UpdatesService
|
||||
BootstrapUpdates store.BootstrapUpdateJobStore
|
||||
BotAPIUpdates store.BotAPIUpdateStore
|
||||
Contacts ContactsService
|
||||
Dialogs DialogsService
|
||||
Chatlists ChatlistsService
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
|
|||
if err != nil {
|
||||
return nil, messageEditErr(err)
|
||||
}
|
||||
r.enqueueBotAPIPrivateEditUpdatesAsync(ctx, res)
|
||||
self := res.Self()
|
||||
if self.Event.Pts == 0 || self.Message.ID == 0 {
|
||||
return nil, messageIDInvalidErr()
|
||||
|
|
|
|||
|
|
@ -193,6 +193,9 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
|||
if err != nil {
|
||||
return nil, messageForwardErr(err)
|
||||
}
|
||||
if !sent.Duplicate {
|
||||
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, sent)
|
||||
}
|
||||
res.SenderMessages = append(res.SenderMessages, sent.SenderMessage)
|
||||
res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage)
|
||||
res.SenderEvents = append(res.SenderEvents, sent.SenderEvent)
|
||||
|
|
|
|||
|
|
@ -216,6 +216,9 @@ func (r *Router) onMessagesSendQuickReplyMessages(ctx context.Context, req *tg.M
|
|||
if err != nil {
|
||||
return nil, messageSendErr(err)
|
||||
}
|
||||
if !sent.Duplicate {
|
||||
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, sent)
|
||||
}
|
||||
res.SenderMessages = append(res.SenderMessages, sent.SenderMessage)
|
||||
res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage)
|
||||
res.SenderEvents = append(res.SenderEvents, sent.SenderEvent)
|
||||
|
|
|
|||
|
|
@ -107,8 +107,12 @@ type Router struct {
|
|||
inlines *inlineRegistry
|
||||
webviews *webViewRegistry
|
||||
loginTokens *loginTokenRegistry
|
||||
botAPIUpdates *botAPIUpdateNotifier
|
||||
instanceID string
|
||||
channelFanout *channelFanoutDispatcher
|
||||
// botAPIEnqueueQueue 把 user→bot 私聊消息的 bot_api_updates 写入移出发送者 RPC 同步
|
||||
// 路径(性能审计 H2);队列满同步回退,绝不丢(队列行是 Bot API 投递真值)。
|
||||
botAPIEnqueueQueue *botAPIEnqueueDispatcher
|
||||
|
||||
// presenceCandidateCache 缓存 presence fan-out 的候选 peer 集合(联系人 ∪ 私聊对端,
|
||||
// online 过滤前),按 userID 短 TTL;零值 sync.Map 即可用,无需构造器初始化。候选集变动
|
||||
|
|
@ -170,8 +174,9 @@ 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, presence: newPresenceTracker(), callbacks: newCallbackRegistry(), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), 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, 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.channelFanout = newChannelFanoutDispatcher(r, defaultChannelFanoutShards, defaultChannelFanoutBuffer)
|
||||
r.botAPIEnqueueQueue = newBotAPIEnqueueDispatcher(log, defaultBotAPIEnqueueBuffer)
|
||||
r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency)
|
||||
r.selfPhotoEchoPushDelay = defaultSelfPhotoEchoPushDelay
|
||||
if cfg.DC > 0 {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ func (s *captureUpdates) CurrentState(_ context.Context, userID int64) (domain.U
|
|||
return s.state, nil
|
||||
}
|
||||
|
||||
func (s *captureUpdates) ConfirmedState(_ context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, bool, error) {
|
||||
s.authKeyID = authKeyID
|
||||
s.userID = userID
|
||||
return s.state, s.state.Pts != 0 || s.state.Date != 0, nil
|
||||
}
|
||||
|
||||
func (s *captureUpdates) AcknowledgeCurrentState(_ context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) {
|
||||
s.authKeyID = authKeyID
|
||||
s.userID = userID
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
|
|||
if !res.Duplicate {
|
||||
// 链接预览 pending 占位:带外解析并就地替换(异步,不阻塞发送 echo)。
|
||||
r.maybeEnqueueWebPageResolve(userID, peer, res.SenderMessage.ID, res.SenderMessage.Media)
|
||||
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res)
|
||||
}
|
||||
return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, p.randomID, true, users, chats), res.Duplicate, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue