feat: sync Bot API gateway support

This commit is contained in:
A 2026-07-09 13:49:24 +08:00
parent 9a501f900a
commit 4c0cc2b7a7
44 changed files with 4609 additions and 49 deletions

View file

@ -0,0 +1,82 @@
package rpc
import (
"context"
"sync/atomic"
"time"
"go.uber.org/zap"
)
// Bot API 私聊 enqueue 异步化(性能审计 H2user→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)
}
}

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

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

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

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

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

View file

@ -273,6 +273,7 @@ func channelMessagesFanoutOwnerIDs(results []domain.SendChannelMessageResult, ex
// 类事件的常见形态:发送/转发单条/讨论组联动/forum topic 消息)。语义与 enqueueChannelFanout 一致,
// 仅多了把每 viewer 投影一次性算好预热进共享 cacheO(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 内含多条 UpdateNewChannelMessagepeer 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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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