merged with fixes

This commit is contained in:
onysd 2026-09-09 02:49:30 +03:00
parent a9e758b712
commit 2f1818d656
176 changed files with 9000 additions and 907 deletions

View file

@ -435,6 +435,9 @@ func mergeAuthorizationClientInfo(a *domain.Authorization, info domain.AuthKeyCl
if info.AppVersion != "" {
a.AppVersion = info.AppVersion
}
if info.IP != "" {
a.IP = info.IP
}
a.ActiveAt = time.Now()
}

View file

@ -0,0 +1,43 @@
package memory
import (
"context"
"testing"
"telesrv/internal/branding"
"telesrv/internal/domain"
)
func TestBuiltInBotSeedsUseConfiguredProductName(t *testing.T) {
previous := branding.Current()
t.Cleanup(func() {
if err := branding.Configure(previous); err != nil {
t.Fatalf("restore branding: %v", err)
}
})
cfg := previous
cfg.ProductName = "Example Chat"
if err := branding.Configure(cfg); err != nil {
t.Fatalf("configure branding: %v", err)
}
ctx := context.Background()
users := NewUserStore()
bots := NewBotStore(users)
for _, test := range []struct {
id int64
want string
}{
{id: domain.ChatBotUserID, want: domain.ChatBotDescription()},
{id: domain.StickersBotUserID, want: domain.StickersBotDescription()},
} {
user, found, err := users.ByID(ctx, test.id)
if err != nil || !found || user.About != test.want {
t.Fatalf("bot user %d = %+v found=%v err=%v, want about %q", test.id, user, found, err, test.want)
}
profile, found, err := bots.GetBot(ctx, test.id)
if err != nil || !found || profile.Description != test.want {
t.Fatalf("bot profile %d = %+v found=%v err=%v, want description %q", test.id, profile, found, err, test.want)
}
}
}

View file

@ -0,0 +1,93 @@
package memory
import (
"context"
"reflect"
"telesrv/internal/domain"
"testing"
)
func TestMediaPaginationBoundaries(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
channels := NewChannelStore()
owner, other := int64(71), int64(72)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner, Title: "media boundaries", Megagroup: true, MemberUserIDs: []int64{other}, Date: 1700000000})
if err != nil {
t.Fatal(err)
}
channel := created.Channel.ID
var privateIDs, channelIDs []int
for i := 1; i <= 12; i++ {
var media *domain.MessageMedia
if i%2 == 1 {
media = &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: int64(i), AccessHash: 99}}
}
a, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: owner, RecipientUserID: other, RandomID: int64(i), Message: "media boundary", Media: media, Date: 1700000000 + i})
if err != nil {
t.Fatal(err)
}
c, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{UserID: owner, ChannelID: channel, RandomID: int64(i), Message: "media boundary", Media: media, Date: 1700000000 + i})
if err != nil {
t.Fatal(err)
}
if media != nil {
privateIDs = append([]int{a.SenderMessage.ID}, privateIDs...)
channelIDs = append([]int{c.Message.ID}, channelIDs...)
}
}
for _, side := range []string{"private", "channel"} {
ids := privateIDs
if side == "channel" {
ids = channelIDs
}
for _, tc := range []struct {
name string
f domain.MediaSearchRequest
want []int
count int
}{
{"all", domain.MediaSearchRequest{Limit: 100}, ids, 6},
{"strict-range", domain.MediaSearchRequest{Limit: 100, MinID: ids[4], MaxID: ids[1]}, ids[2:4], 2},
{"strict-count", domain.MediaSearchRequest{Limit: 0, MinID: ids[4], MaxID: ids[1], OffsetID: ids[2], AddOffset: -2}, nil, 2},
{"around-existing", domain.MediaSearchRequest{Limit: 4, OffsetID: ids[2], AddOffset: -2}, ids[1:5], 6},
{"after-missing", domain.MediaSearchRequest{Limit: 2, OffsetID: ids[4] + 1, AddOffset: -2}, ids[2:4], 6},
{"forward-gap", domain.MediaSearchRequest{Limit: 2, OffsetID: ids[4], AddOffset: -4}, ids[1:3], 6},
{"empty-forward", domain.MediaSearchRequest{Limit: 2, OffsetID: ids[0] + 1, AddOffset: -2}, nil, 6},
{"around-top", domain.MediaSearchRequest{Limit: 4, OffsetID: ids[0] + 1, AddOffset: -2}, ids[:2], 6},
{"around-zero", domain.MediaSearchRequest{Limit: 4, AddOffset: -2}, ids[:2], 6},
{"empty-backward", domain.MediaSearchRequest{Limit: 2, AddOffset: 100}, nil, 6},
} {
t.Run(side+"/"+tc.name, func(t *testing.T) {
f := tc.f
f.Categories = []domain.MediaCategory{domain.MediaCategoryPhoto, domain.MediaCategoryPhoto}
f.Query = "boundary"
var got []int
var count int
if side == "private" {
r, err := messages.SearchPrivateMedia(ctx, owner, other, f)
if err != nil {
t.Fatal(err)
}
count = r.Count
for _, m := range r.Messages {
got = append(got, m.ID)
}
} else {
r, err := channels.SearchChannelMedia(ctx, owner, channel, f)
if err != nil {
t.Fatal(err)
}
count = r.Count
for _, m := range r.Messages {
got = append(got, m.ID)
}
}
if count != tc.count || !reflect.DeepEqual(append([]int{}, got...), append([]int{}, tc.want...)) {
t.Fatalf("ids=%v count=%d want %v/%d", got, count, tc.want, tc.count)
}
})
}
}
}

View file

@ -67,30 +67,16 @@ func pageMediaIDs(ids []int, req domain.MediaSearchRequest) ([]int, int) {
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
inRange := make([]int, 0, len(ids))
for _, id := range ids {
if req.MaxID != 0 && id > req.MaxID {
if req.MaxID != 0 && id >= req.MaxID {
continue
}
if req.MinID != 0 && id < req.MinID {
if req.MinID != 0 && id <= req.MinID {
continue
}
inRange = append(inRange, id)
}
count := len(inRange)
page := make([]int, 0, len(inRange))
for _, id := range inRange {
if req.OffsetID != 0 && id >= req.OffsetID {
continue
}
page = append(page, id)
}
off := req.AddOffset
if off < 0 {
off = 0
}
if off > len(page) {
off = len(page)
}
page = page[off:]
limit := req.Limit
if limit == 0 {
return nil, count
@ -98,9 +84,15 @@ func pageMediaIDs(ids []int, req domain.MediaSearchRequest) ([]int, int) {
if limit < 0 || limit > 100 {
limit = 100
}
if len(page) > limit {
page = page[:limit]
pivot := 0
if req.OffsetID > 0 {
pivot = sort.Search(len(inRange), func(i int) bool { return inRange[i] < req.OffsetID })
}
start := pivot + domain.ClampMessageHistoryAddOffset(req.AddOffset)
end := start + limit
start = min(max(start, 0), len(inRange))
end = min(max(end, start), len(inRange))
page := inRange[start:end]
return page, count
}

View file

@ -186,12 +186,7 @@ func richMessagesEqual(a, b *domain.MessageRichMessage) bool {
}
func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply {
if reply == nil {
return nil
}
clone := *reply
clone.QuoteEntities = append([]domain.MessageEntity(nil), reply.QuoteEntities...)
return &clone
return domain.CloneMessageReply(reply)
}
func cloneMessageForward(forward *domain.MessageForward) *domain.MessageForward {

View file

@ -295,7 +295,7 @@ func (s *MessageStore) memoryHistoryClearAnchorLocked(userID int64, peer domain.
func filterMessageList(messages []domain.Message, filter domain.MessageFilter) domain.MessageList {
filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset)
sort.SliceStable(messages, func(i, j int) bool {
return messageLess(messages[i], messages[j])
return messages[i].ID > messages[j].ID
})
query := strings.ToLower(filter.Query)
@ -308,6 +308,9 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d
if filter.HasPeer && msg.Peer != filter.Peer {
continue
}
if filter.SenderUserID != 0 && msg.From != (domain.Peer{Type: domain.PeerTypeUser, ID: filter.SenderUserID}) {
continue
}
if filter.RestrictPeerIDs {
if msg.Peer.Type != domain.PeerTypeUser {
continue
@ -346,6 +349,9 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d
base = append(base, msg)
}
if filter.CountOnly {
return domain.MessageList{Count: len(base)}
}
limit := filter.Limit
if limit <= 0 {
limit = 100
@ -384,7 +390,7 @@ func pageMessageHistory(base []domain.Message, filter domain.MessageFilter, limi
}
switch messageHistoryLoadType(filter.AddOffset, limit) {
case messageHistoryLoadForward:
return cloneMessages(forwardMessageHistory(base, filter, limit))
return cloneMessages(forwardMessageHistory(base, filter, limit, -filter.AddOffset-limit))
case messageHistoryLoadAround:
forwardLimit := -filter.AddOffset
if forwardLimit > limit {
@ -395,10 +401,10 @@ func pageMessageHistory(base []domain.Message, filter domain.MessageFilter, limi
backwardLimit = 0
}
page := make([]domain.Message, 0, limit)
page = append(page, forwardMessageHistory(base, filter, forwardLimit)...)
page = append(page, backwardMessageHistory(base, filter, backwardLimit, true)...)
page = append(page, forwardMessageHistory(base, filter, forwardLimit, 0)...)
page = append(page, backwardMessageHistory(base, filter, backwardLimit)...)
sort.SliceStable(page, func(i, j int) bool {
return messageLess(page[i], page[j])
return page[i].ID > page[j].ID
})
return cloneMessages(page)
default:
@ -406,7 +412,7 @@ func pageMessageHistory(base []domain.Message, filter domain.MessageFilter, limi
if start < 0 {
start = 0
}
candidates := backwardMessageHistory(base, filter, limit+start, false)
candidates := backwardMessageHistory(base, filter, limit+start)
if start >= len(candidates) {
return nil
}
@ -432,13 +438,13 @@ func messageHistoryLoadType(addOffset, limit int) messageHistoryLoad {
return messageHistoryLoadForward
}
func backwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int, includeOffset bool) []domain.Message {
func backwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message {
if limit <= 0 {
return nil
}
out := make([]domain.Message, 0, limit)
for _, msg := range base {
if !messageBeforeHistoryOffset(msg, filter, includeOffset) {
if !messageBeforeHistoryOffset(msg, filter) {
continue
}
out = append(out, msg)
@ -449,7 +455,7 @@ func backwardMessageHistory(base []domain.Message, filter domain.MessageFilter,
return out
}
func forwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message {
func forwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit, skip int) []domain.Message {
if limit <= 0 {
return nil
}
@ -459,30 +465,28 @@ func forwardMessageHistory(base []domain.Message, filter domain.MessageFilter, l
if !messageAfterHistoryOffset(msg, filter) {
continue
}
if skip > 0 {
skip--
continue
}
out = append(out, msg)
if len(out) == limit {
break
}
}
sort.SliceStable(out, func(i, j int) bool {
return messageLess(out[i], out[j])
return out[i].ID > out[j].ID
})
return out
}
func messageBeforeHistoryOffset(msg domain.Message, filter domain.MessageFilter, includeOffset bool) bool {
func messageBeforeHistoryOffset(msg domain.Message, filter domain.MessageFilter) bool {
if filter.OffsetDate > 0 {
if includeOffset {
return msg.Date <= filter.OffsetDate
}
return msg.Date < filter.OffsetDate
}
if filter.OffsetID <= 0 {
return true
}
if includeOffset {
return msg.ID <= filter.OffsetID
}
return msg.ID < filter.OffsetID
}
@ -493,7 +497,7 @@ func messageAfterHistoryOffset(msg domain.Message, filter domain.MessageFilter)
if filter.OffsetID <= 0 {
return false
}
return msg.ID > filter.OffsetID
return msg.ID >= filter.OffsetID
}
func messageLess(a, b domain.Message) bool {

View file

@ -0,0 +1,51 @@
package memory
import (
"reflect"
"telesrv/internal/domain"
"testing"
)
func TestMessageSearchSenderAndCountFilters(t *testing.T) {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 22}
var messages []domain.Message
for i := 1; i <= 6; i++ {
sender := int64(11)
if i%2 == 0 {
sender = 22
}
messages = append(messages, domain.Message{ID: i, OwnerUserID: 11, Peer: peer, From: domain.Peer{Type: domain.PeerTypeUser, ID: sender}, Body: "count needle", Date: 100 + i, Pinned: i == 3})
}
for _, tt := range []struct {
name string
f domain.MessageFilter
ids []int
count int
}{
{"sender", domain.MessageFilter{SenderUserID: 11, Limit: 10}, []int{5, 3, 1}, 3},
{"sender-around", domain.MessageFilter{SenderUserID: 11, OffsetID: 3, AddOffset: -1, Limit: 3}, []int{3, 1}, 3},
{"count-ignores-page", domain.MessageFilter{SenderUserID: 11, CountOnly: true, OffsetID: 1, AddOffset: 100}, nil, 3},
{"count-intersection", domain.MessageFilter{SenderUserID: 11, CountOnly: true, MinDate: 101, MaxDate: 106, MinID: 1, MaxID: 5}, nil, 1},
{"count-pinned", domain.MessageFilter{SenderUserID: 11, CountOnly: true, PinnedOnly: true}, nil, 1},
{"count-pinned-other", domain.MessageFilter{SenderUserID: 22, CountOnly: true, PinnedOnly: true}, nil, 0},
{"count-peer-isolation", domain.MessageFilter{CountOnly: true, RestrictPeerIDs: true, PeerIDs: []int64{33}}, nil, 0},
} {
t.Run(tt.name, func(t *testing.T) {
f := tt.f
f.HasPeer = true
f.Peer = peer
f.Query = "needle"
got := filterMessageList(cloneMessages(messages), f)
var ids []int
for _, m := range got.Messages {
ids = append(ids, m.ID)
}
if !reflect.DeepEqual(ids, tt.ids) || got.Count != tt.count {
t.Fatalf("ids=%v count=%d want %v/%d", ids, got.Count, tt.ids, tt.count)
}
if f.CountOnly && len(got.Users) > 0 {
t.Fatal("count projected users")
}
})
}
}

View file

@ -52,8 +52,26 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
if err != nil {
return domain.SendPrivateTextResult{}, err
}
// Match TogglePrivateNoForwards lock order; only external private-source
// replies read pair protection while holding the message mutation lock.
if r := req.ReplyTo; r != nil && r.Peer.Type == domain.PeerTypeUser && r.Peer.ID > 0 && r.Peer.ID != req.RecipientUserID {
s.noForwardsMu.Lock()
defer s.noForwardsMu.Unlock()
}
s.mu.Lock()
defer s.mu.Unlock()
return s.sendPrivateTextLocked(req, fingerprint, nil)
}
// A prefix stages related state events before the message. apply is infallible
// and only runs after all validation and receipt encoding have succeeded.
type memoryPrivateSendPrefix struct {
events []domain.UpdateEvent
apply func([]domain.UpdateEvent)
}
// Caller holds s.mu; this helper owns the dialog/event locks through commit.
func (s *MessageStore) sendPrivateTextLocked(req domain.SendPrivateTextRequest, fingerprint []byte, prefix *memoryPrivateSendPrefix) (domain.SendPrivateTextResult, error) {
if replay, found, err := s.lookupPrivateSendReplayLocked(domain.PrivateSendReplayRequest{
SenderUserID: req.SenderUserID,
RecipientUserID: req.RecipientUserID,
@ -69,10 +87,37 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
if err != nil {
return domain.SendPrivateTextResult{}, err
}
events := s.updateEvents
if events == nil {
return domain.SendPrivateTextResult{}, store.ErrDeliveryOutboxRequired
}
events.mu.Lock()
defer events.mu.Unlock()
// Stage counters as well as the message: failed encoding cannot consume PTS.
allocatedPTS := make(map[int64]int)
nextPTS := func(userID int64) int {
current, ok := allocatedPTS[userID]
if !ok {
current = s.nextPts[userID]
for _, event := range events.events[userID] {
if event.Pts > current {
current = event.Pts
}
}
}
allocatedPTS[userID] = current + 1
return current + 1
}
var prefixEvents []domain.UpdateEvent
if prefix != nil {
for _, event := range prefix.events {
event.Pts = nextPTS(event.UserID)
prefixEvents = append(prefixEvents, event)
}
}
uid := s.nextUID
s.nextUID++
sender := domain.Message{
ID: s.nextBoxIDLocked(req.SenderUserID),
ID: s.nextBox[req.SenderUserID] + 1,
UID: uid,
RandomID: req.RandomID,
OwnerUserID: req.SenderUserID,
@ -92,7 +137,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
RichMessage: cloneRichMessage(req.RichMessage),
ReplyTo: cloneMessageReply(senderReply),
Forward: cloneMessageForward(req.Forward),
Pts: s.nextPtsLocked(req.SenderUserID),
Pts: nextPTS(req.SenderUserID),
// voice/round 在发送者副本上同样保持"未听",由对端内容已读清除。
MediaUnread: req.Media.HasUnreadPayload() && req.SenderUserID != req.RecipientUserID,
}
@ -105,7 +150,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
}
if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked {
recipient = sender
recipient.ID = s.nextBoxIDLocked(req.RecipientUserID)
recipient.ID = s.nextBox[req.RecipientUserID] + 1
recipient.OwnerUserID = req.RecipientUserID
recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
recipient.Out = false
@ -115,7 +160,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
// 让双盒各持独立快照(与 postgres 每盒独立 decode 对齐I3/I2
recipient.ReplyMarkup = cloneReplyMarkup(sender.ReplyMarkup)
recipient.RichMessage = cloneRichMessage(sender.RichMessage)
recipient.Pts = s.nextPtsLocked(req.RecipientUserID)
recipient.Pts = nextPTS(req.RecipientUserID)
recipient.MediaUnread = req.Media.HasUnreadPayload()
}
var senderSnapshot []byte
@ -125,6 +170,25 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
return domain.SendPrivateTextResult{}, err
}
}
// No fallible work follows: publish the prefix and message under all locks.
if prefix != nil {
prefix.apply(prefixEvents)
}
for _, event := range prefixEvents {
auth, session := [8]byte{}, int64(0)
if event.UserID == req.SenderUserID {
auth, session = req.OriginAuthKeyID, req.OriginSessionID
}
appendMemorySendEventLocked(events, event.UserID, event, auth, session)
}
s.nextUID++
s.nextBox[req.SenderUserID] = sender.ID
if recipient.ID != 0 {
s.nextBox[recipient.OwnerUserID] = recipient.ID
}
for userID, pts := range allocatedPTS {
s.nextPts[userID] = pts
}
s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender)
if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked {
s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient)
@ -144,6 +208,22 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
s.upsertMemoryDialogsLocked(sender, sender)
}
}
originUserID := req.OriginUserID
if originUserID == 0 {
originUserID = req.SenderUserID
}
senderExcludeAuthKeyID, senderExcludeSessionID := [8]byte{}, int64(0)
if originUserID == req.SenderUserID {
senderExcludeAuthKeyID, senderExcludeSessionID = req.OriginAuthKeyID, req.OriginSessionID
}
appendMemorySendEventLocked(events, req.SenderUserID, newMessageEvent(sender), senderExcludeAuthKeyID, senderExcludeSessionID)
if recipient.ID != 0 && recipient.OwnerUserID != sender.OwnerUserID {
recipientExcludeAuthKeyID, recipientExcludeSessionID := [8]byte{}, int64(0)
if originUserID == req.RecipientUserID {
recipientExcludeAuthKeyID, recipientExcludeSessionID = req.OriginAuthKeyID, req.OriginSessionID
}
appendMemorySendEventLocked(events, req.RecipientUserID, newMessageEvent(recipient), recipientExcludeAuthKeyID, recipientExcludeSessionID)
}
return domain.SendPrivateTextResult{
SenderMessage: cloneMessage(sender),
RecipientMessage: cloneMessage(recipient),
@ -152,6 +232,25 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
}, nil
}
func (s *MessageStore) nextPtsWithEventsLocked(events *UpdateEventStore, userID int64) int {
current := s.nextPts[userID]
for _, event := range events.events[userID] {
if event.Pts > current {
current = event.Pts
}
}
current++
s.nextPts[userID] = current
return current
}
func appendMemorySendEventLocked(events *UpdateEventStore, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) {
event = events.appendLocked(userID, event, false)
events.dispatches[userID] = append(events.dispatches[userID], memoryUpdateDispatch{
Pts: event.Pts, ExcludeAuthKeyID: excludeAuthKeyID, ExcludeSessionID: excludeSessionID,
})
}
// LookupPrivateSendReplay returns an existing immutable/current replay receipt without running
// any send permission, reply resolution or allocation path.
func (s *MessageStore) LookupPrivateSendReplay(_ context.Context, req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) {
@ -246,7 +345,7 @@ func (s *MessageStore) resolveMemoryReplyLocked(req domain.SendPrivateTextReques
if peer.ID == 0 {
peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}
}
if peer.Type != domain.PeerTypeUser || peer.ID != req.RecipientUserID {
if peer.Type != domain.PeerTypeUser || req.ReplyTo.External != nil {
return nil, nil, domain.ErrReplyMessageIDInvalid
}
var target domain.Message
@ -262,6 +361,29 @@ func (s *MessageStore) resolveMemoryReplyLocked(req domain.SendPrivateTextReques
senderReply := cloneMessageReply(req.ReplyTo)
senderReply.MessageID = target.ID
senderReply.Peer = peer
if peer.ID != req.RecipientUserID {
protected := target.NoForwards || (target.Media != nil && target.Media.TTLSeconds > 0)
if pair, ok := noForwardsPair(req.SenderUserID, peer.ID); ok {
protected = protected || s.privateNoForwards[pair].Enabled()
}
if protected {
return nil, nil, domain.ErrChatForwardsRestricted
}
if err := domain.ValidateExternalReplyQuote(req.ReplyTo, target.Body); err != nil {
return nil, nil, err
}
var err error
senderReply.External, err = domain.NewMessageReplyExternal(target)
if err != nil {
return nil, nil, err
}
recipientReply := cloneMessageReply(senderReply)
if req.SenderUserID != req.RecipientUserID {
recipientReply.MessageID = 0
recipientReply.TopMessageID = 0
}
return senderReply, recipientReply, nil
}
if req.SenderUserID == req.RecipientUserID {
return senderReply, cloneMessageReply(senderReply), nil
}

View file

@ -21,6 +21,7 @@ type MessageStore struct {
loginCodeDeliveries map[[32]byte]loginCodeDeliveryRecord
albumGroups map[albumGroupKey]albumGroupRecord
dialogs *DialogStore
updateEvents *UpdateEventStore
// polls 是共享 poll 权威(投票校验与读路径 enrichmentnil 时 poll 链路按未接入处理。
polls *PollStore
// savedPins 是收藏夹子会话置顶顺序(下标即 pinned_order越小越前
@ -37,6 +38,10 @@ func (s *MessageStore) AttachPollStore(polls *PollStore) {
s.polls = polls
}
func (s *MessageStore) AttachUpdateEventStore(events *UpdateEventStore) {
s.updateEvents = events
}
type readOutboxDateKey struct {
ownerUserID int64
peerID int64
@ -60,6 +65,7 @@ func NewMessageStore(dialogs ...*DialogStore) *MessageStore {
savedPins: make(map[int64][]domain.Peer),
privateNoForwards: make(map[privateNoForwardsPair]domain.PrivateNoForwardsState),
privateNoForwardsRequests: make(map[int64]memoryNoForwardsRequest),
updateEvents: NewUpdateEventStore(),
}
if len(dialogs) > 0 {
s.dialogs = dialogs[0]

View file

@ -569,7 +569,7 @@ func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing
if err != nil {
t.Fatalf("around history: %v", err)
}
if got := messageIDs(around.Messages); !sameInts(got, []int{6, 5, 4, 3, 2, 1}) {
if got := messageIDs(around.Messages); !sameInts(got, []int{5, 4, 3, 2, 1}) {
t.Fatalf("around ids = %v, want unread/newer side plus older context", got)
}
@ -583,8 +583,8 @@ func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing
if err != nil {
t.Fatalf("forward history: %v", err)
}
if got := messageIDs(forward.Messages); !sameInts(got, []int{6, 5, 4}) {
t.Fatalf("forward ids = %v, want messages newer than offset", got)
if got := messageIDs(forward.Messages); !sameInts(got, []int{5, 4, 3}) {
t.Fatalf("forward ids = %v, want forward window including offset anchor", got)
}
hugePositive, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{
@ -610,8 +610,8 @@ func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing
if err != nil {
t.Fatalf("huge negative add_offset history: %v", err)
}
if got := messageIDs(hugeNegative.Messages); !sameInts(got, []int{6, 5, 4}) {
t.Fatalf("huge negative add_offset ids = %v, want clamped forward page", got)
if got := messageIDs(hugeNegative.Messages); !sameInts(got, nil) {
t.Fatalf("huge negative add_offset ids = %v, want empty page after clamped forward gap", got)
}
}

View file

@ -16,8 +16,15 @@ type UpdateStateStore struct {
// UpdateEventStore 是 store.UpdateEventStore 的内存实现。
type UpdateEventStore struct {
mu sync.RWMutex
events map[int64][]domain.UpdateEvent
mu sync.RWMutex
events map[int64][]domain.UpdateEvent
dispatches map[int64][]memoryUpdateDispatch
}
type memoryUpdateDispatch struct {
Pts int
ExcludeAuthKeyID [8]byte
ExcludeSessionID int64
}
type updateStateKey struct {
@ -27,29 +34,46 @@ type updateStateKey struct {
// NewUpdateEventStore 创建内存 UpdateEventStore。
func NewUpdateEventStore() *UpdateEventStore {
return &UpdateEventStore{events: make(map[int64][]domain.UpdateEvent)}
return &UpdateEventStore{
events: make(map[int64][]domain.UpdateEvent),
dispatches: make(map[int64][]memoryUpdateDispatch),
}
}
func (s *UpdateEventStore) Append(_ context.Context, userID int64, event domain.UpdateEvent) error {
_, err := s.append(userID, event, false)
_, err := s.append(userID, event, false, false, [8]byte{}, 0)
return err
}
func (s *UpdateEventStore) AppendAllocated(_ context.Context, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, error) {
return s.append(userID, event, true)
return s.append(userID, event, true, false, [8]byte{}, 0)
}
func (s *UpdateEventStore) AppendAllocatedWithDispatch(_ context.Context, userID int64, event domain.UpdateEvent, _ [8]byte, _ int64) (domain.UpdateEvent, error) {
return s.append(userID, event, true)
func (s *UpdateEventStore) AppendAllocatedWithDispatch(_ context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, error) {
return s.append(userID, event, true, true, excludeAuthKeyID, excludeSessionID)
}
func (s *UpdateEventStore) append(userID int64, event domain.UpdateEvent, allocate bool) (domain.UpdateEvent, error) {
func (s *UpdateEventStore) append(userID int64, event domain.UpdateEvent, allocate, withDispatch bool, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, error) {
if withDispatch && (excludeAuthKeyID == ([8]byte{})) != (excludeSessionID == 0) {
return domain.UpdateEvent{}, fmt.Errorf("update dispatch exclusion requires both raw auth key and session id")
}
s.mu.Lock()
defer s.mu.Unlock()
event = s.appendLocked(userID, event, allocate)
if withDispatch {
s.dispatches[userID] = append(s.dispatches[userID], memoryUpdateDispatch{
Pts: event.Pts, ExcludeAuthKeyID: excludeAuthKeyID, ExcludeSessionID: excludeSessionID,
})
}
return event, nil
}
func (s *UpdateEventStore) appendLocked(userID int64, event domain.UpdateEvent, allocate bool) domain.UpdateEvent {
if event.PtsCount <= 0 {
event.PtsCount = 1
}
event.UserID = userID
event = cloneUpdateEvent(event)
s.mu.Lock()
if allocate {
current := 0
for _, item := range s.events[userID] {
@ -60,8 +84,7 @@ func (s *UpdateEventStore) append(userID int64, event domain.UpdateEvent, alloca
event.Pts = current + event.PtsCount
}
s.events[userID] = append(s.events[userID], event)
s.mu.Unlock()
return event, nil
return event
}
func (s *UpdateEventStore) ListAfter(_ context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) {