merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
|
|
@ -79,6 +79,23 @@ func (s *AuthKeyStore) Get(_ context.Context, id [8]byte) (store.AuthKeyData, bo
|
|||
return k, ok, nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) Revalidate(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) LoadBindingKeys(_ context.Context, tempID, permID [8]byte) (store.AuthKeyBindingKeys, error) {
|
||||
s.state.mu.RLock()
|
||||
temp, tempFound := s.state.keys[tempID]
|
||||
perm, permFound := s.state.keys[permID]
|
||||
s.state.mu.RUnlock()
|
||||
return store.AuthKeyBindingKeys{
|
||||
Temporary: temp,
|
||||
TemporaryFound: tempFound,
|
||||
Permanent: perm,
|
||||
PermanentFound: permFound,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) UpdateClientInfo(_ context.Context, id [8]byte, info store.AuthKeyClientInfo) error {
|
||||
s.state.mu.Lock()
|
||||
k, ok := s.state.keys[id]
|
||||
|
|
@ -198,19 +215,24 @@ func NewTempAuthKeyBindingStore(authKeys *AuthKeyStore) *TempAuthKeyBindingStore
|
|||
return &TempAuthKeyBindingStore{state: authKeys.state}
|
||||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) Save(_ context.Context, b domain.TempAuthKeyBinding) error {
|
||||
func (s *TempAuthKeyBindingStore) Save(ctx context.Context, b domain.TempAuthKeyBinding) error {
|
||||
_, err := s.SaveWithState(ctx, b)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) SaveWithState(_ context.Context, b domain.TempAuthKeyBinding) (domain.TempAuthKeyBindingResult, error) {
|
||||
b.EncryptedMessage = append([]byte(nil), b.EncryptedMessage...)
|
||||
s.state.mu.Lock()
|
||||
defer s.state.mu.Unlock()
|
||||
if current, ok := s.state.bindings[b.TempAuthKeyID]; ok && current.PermAuthKeyID != b.PermAuthKeyID {
|
||||
return store.ErrTempAuthKeyAlreadyBound
|
||||
return domain.TempAuthKeyBindingResult{}, store.ErrTempAuthKeyAlreadyBound
|
||||
}
|
||||
temp, tempFound := s.state.keys[b.TempAuthKeyID]
|
||||
var permID [8]byte
|
||||
binary.LittleEndian.PutUint64(permID[:], uint64(b.PermAuthKeyID))
|
||||
perm, permFound := s.state.keys[permID]
|
||||
if !tempFound || !permFound || temp.ExpiresAt <= 0 || perm.ExpiresAt != 0 || b.ExpiresAt != temp.ExpiresAt {
|
||||
return store.ErrAuthKeyBindingInvalid
|
||||
return domain.TempAuthKeyBindingResult{}, store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
// Binding and Layer-default normalization are one state transition. Exact
|
||||
// session evidence remains keyed by the raw temp key; only the inherited
|
||||
|
|
@ -220,7 +242,7 @@ func (s *TempAuthKeyBindingStore) Save(_ context.Context, b domain.TempAuthKeyBi
|
|||
perm.Layer, perm.LayerObservationID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return domain.TempAuthKeyBindingResult{}, err
|
||||
}
|
||||
temp.Layer, temp.LayerObservationID = layer, observationID
|
||||
perm.Layer, perm.LayerObservationID = layer, observationID
|
||||
|
|
@ -228,7 +250,7 @@ func (s *TempAuthKeyBindingStore) Save(_ context.Context, b domain.TempAuthKeyBi
|
|||
s.state.keys[permID] = perm
|
||||
s.state.bindings[b.TempAuthKeyID] = b
|
||||
s.state.mirrorAuthorizationLayersLocked([][8]byte{b.TempAuthKeyID, permID}, layer)
|
||||
return nil
|
||||
return domain.TempAuthKeyBindingResult{Layer: layer, LayerObservationID: observationID}, nil
|
||||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) GetByTemp(_ context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) {
|
||||
|
|
@ -319,9 +341,9 @@ func (s *AuthorizationStore) Bind(_ context.Context, a domain.Authorization) err
|
|||
if a.Hash == 0 {
|
||||
a.Hash = int64(binary.LittleEndian.Uint64(a.AuthKeyID[:]))
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = now
|
||||
}
|
||||
// Bind is an explicit login boundary. Metadata-only refreshes use
|
||||
// UpdateClientInfo and must not reset the session age.
|
||||
a.CreatedAt = now
|
||||
a.ActiveAt = now
|
||||
s.linkMu.RLock()
|
||||
if s.authKeys != nil {
|
||||
|
|
@ -353,9 +375,6 @@ func (s *AuthorizationStore) Bind(_ context.Context, a domain.Authorization) err
|
|||
}
|
||||
|
||||
func (s *AuthorizationStore) bindLocked(a domain.Authorization) {
|
||||
if existing, ok := s.m[a.AuthKeyID]; ok && !existing.CreatedAt.IsZero() {
|
||||
a.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
s.m[a.AuthKeyID] = a
|
||||
}
|
||||
|
||||
|
|
@ -419,14 +438,18 @@ func mergeAuthorizationClientInfo(a *domain.Authorization, info domain.AuthKeyCl
|
|||
a.ActiveAt = time.Now()
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) MarkPasswordPassed(_ context.Context, id [8]byte) error {
|
||||
func (s *AuthorizationStore) MarkPasswordPassed(_ context.Context, id [8]byte, expectedUserID int64) error {
|
||||
s.mu.Lock()
|
||||
if a, ok := s.m[id]; ok {
|
||||
a.PasswordPending = false
|
||||
a.ActiveAt = time.Now()
|
||||
s.m[id] = a
|
||||
defer s.mu.Unlock()
|
||||
a, ok := s.m[id]
|
||||
if !ok || expectedUserID == 0 || a.UserID != expectedUserID || !a.PasswordPending {
|
||||
return store.ErrAuthorizationStateChanged
|
||||
}
|
||||
s.mu.Unlock()
|
||||
now := time.Now()
|
||||
a.PasswordPending = false
|
||||
a.CreatedAt = now
|
||||
a.ActiveAt = now
|
||||
s.m[id] = a
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,15 @@ func (s *AuthKeyStore) AdvanceSessionLayer(
|
|||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
}
|
||||
if found && now.Before(current.ExpiresAt) && layer == current.Layer {
|
||||
current.MessageID = msgID
|
||||
current.ExpiresAt = expiresAt
|
||||
stored := current
|
||||
stored.SharedDefault = false
|
||||
s.state.sessionLayers[key] = stored
|
||||
current.SharedDefault = s.state.sessionLayerIsSharedDefaultLocked(rawAuthKeyID, current)
|
||||
return current, true, nil
|
||||
}
|
||||
if s.state.nextLayerObservation == math.MaxInt64 {
|
||||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ func TestAuthKeySessionLayerOrdersRestartEvidenceAndBindingDefaults(t *testing.T
|
|||
}
|
||||
now := time.Now().UTC()
|
||||
firstMsgID := authKeySessionLayerTestMsgID(now, 1)
|
||||
newerMsgID := authKeySessionLayerTestMsgID(now, 2)
|
||||
otherMsgID := authKeySessionLayerTestMsgID(now, 3)
|
||||
sameLayerMsgID := authKeySessionLayerTestMsgID(now, 2)
|
||||
newerMsgID := authKeySessionLayerTestMsgID(now, 3)
|
||||
otherMsgID := authKeySessionLayerTestMsgID(now, 4)
|
||||
|
||||
first, applied, err := keys.AdvanceSessionLayer(ctx, temp, 10, 220, firstMsgID)
|
||||
if err != nil || !applied || !first.SharedDefault || first.ObservationID <= 0 {
|
||||
|
|
@ -48,6 +49,17 @@ func TestAuthKeySessionLayerOrdersRestartEvidenceAndBindingDefaults(t *testing.T
|
|||
t.Fatalf("bound default %x = (%+v,%v,%v)", id, got, found, err)
|
||||
}
|
||||
}
|
||||
sameLayer, applied, err := keys.AdvanceSessionLayer(ctx, temp, 10, 220, sameLayerMsgID)
|
||||
if err != nil || !applied || !sameLayer.SharedDefault || sameLayer.MessageID != sameLayerMsgID ||
|
||||
sameLayer.ObservationID != first.ObservationID {
|
||||
t.Fatalf("same-Layer high-water advance = (%+v,%v,%v)", sameLayer, applied, err)
|
||||
}
|
||||
for _, id := range [][8]byte{temp, perm} {
|
||||
got, found, err := keys.Get(ctx, id)
|
||||
if err != nil || !found || got.Layer != 220 || got.LayerObservationID != first.ObservationID {
|
||||
t.Fatalf("same-Layer default rewrite %x = (%+v,%v,%v)", id, got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
newer, applied, err := keys.AdvanceSessionLayer(ctx, temp, 10, 227, newerMsgID)
|
||||
if err != nil || !applied || !newer.SharedDefault || newer.ObservationID <= first.ObservationID {
|
||||
|
|
|
|||
63
internal/store/memory/authorization_test.go
Normal file
63
internal/store/memory/authorization_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestAuthorizationLoginAgeAndPasswordPromotionCAS(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
auths := NewAuthorizationStore()
|
||||
key := [8]byte{0x91}
|
||||
old := time.Now().Add(-48 * time.Hour)
|
||||
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: key, UserID: 101, CreatedAt: old,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, found, err := auths.ByAuthKey(ctx, key)
|
||||
if err != nil || !found || !first.CreatedAt.After(old) {
|
||||
t.Fatalf("first login authorization=%+v found=%v err=%v", first, found, err)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: 101}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _, _ := auths.ByAuthKey(ctx, key)
|
||||
if !second.CreatedAt.After(first.CreatedAt) {
|
||||
t.Fatalf("same-owner login kept created_at=%v, want newer than %v", second.CreatedAt, first.CreatedAt)
|
||||
}
|
||||
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: key, UserID: 101, PasswordPending: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: key, UserID: 202, PasswordPending: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pendingB, _, _ := auths.ByAuthKey(ctx, key)
|
||||
if err := auths.MarkPasswordPassed(ctx, key, 101); !errors.Is(err, store.ErrAuthorizationStateChanged) {
|
||||
t.Fatalf("stale A proof err=%v, want state changed", err)
|
||||
}
|
||||
stillPendingB, _, _ := auths.ByAuthKey(ctx, key)
|
||||
if stillPendingB.UserID != 202 || !stillPendingB.PasswordPending {
|
||||
t.Fatalf("stale A proof changed B authorization: %+v", stillPendingB)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
if err := auths.MarkPasswordPassed(ctx, key, 202); err != nil {
|
||||
t.Fatalf("promote B: %v", err)
|
||||
}
|
||||
passedB, _, _ := auths.ByAuthKey(ctx, key)
|
||||
if passedB.PasswordPending || !passedB.CreatedAt.After(pendingB.CreatedAt) {
|
||||
t.Fatalf("promoted B authorization=%+v, want fresh fully-authorized session", passedB)
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,10 @@ func NewBotStore(users *UserStore) *BotStore {
|
|||
s.byID[domain.BotFatherUserID] = botFatherSeedProfile()
|
||||
s.byID[domain.StickersBotUserID] = stickersSeedProfile()
|
||||
s.byID[domain.ChatBotUserID] = chatBotSeedProfile()
|
||||
s.byID[domain.GifBotUserID] = domain.BotProfile{
|
||||
BotUserID: domain.GifBotUserID, OwnerUserID: domain.GifBotUserID,
|
||||
Description: "Search the server-curated GIF catalog.", InlinePlaceholder: "Search GIFs",
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +93,7 @@ func stickersSeedProfile() domain.BotProfile {
|
|||
return domain.BotProfile{
|
||||
BotUserID: domain.StickersBotUserID,
|
||||
OwnerUserID: domain.StickersBotUserID,
|
||||
Description: "Create custom sticker and emoji packs for telesrv.",
|
||||
Description: domain.StickersBotDescription(),
|
||||
Commands: []domain.BotCommand{
|
||||
{Command: "start", Description: "start the sticker pack assistant"},
|
||||
{Command: "help", Description: "show help"},
|
||||
|
|
@ -108,7 +112,7 @@ func chatBotSeedProfile() domain.BotProfile {
|
|||
return domain.BotProfile{
|
||||
BotUserID: domain.ChatBotUserID,
|
||||
OwnerUserID: domain.ChatBotUserID,
|
||||
Description: "Chat with the configured telesrv AI provider.",
|
||||
Description: domain.ChatBotDescription(),
|
||||
Commands: []domain.BotCommand{
|
||||
{Command: "start", Description: "start chatting"},
|
||||
{Command: "help", Description: "show help"},
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *PasswordStore) HasBusinessAutomation(_ context.Context, userID int64) (bool, error) {
|
||||
s.mu.RLock()
|
||||
profile, hasProfile := s.businessProfiles[userID]
|
||||
bot, hasBot := s.connectedBusinessBots[userID]
|
||||
s.mu.RUnlock()
|
||||
return hasProfile && (profile.Greeting != nil || profile.Away != nil) || hasBot && bot.BotUserID != 0, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetBusinessProfile(_ context.Context, userID int64) (domain.BusinessProfile, bool, error) {
|
||||
s.mu.RLock()
|
||||
profile, ok := s.businessProfiles[userID]
|
||||
|
|
|
|||
|
|
@ -56,6 +56,13 @@ func (s *ChannelStore) CreateChannel(_ context.Context, req domain.CreateChannel
|
|||
AdminRights: domain.CreatorChannelAdminRights(),
|
||||
}
|
||||
s.channels[channelID] = channel
|
||||
// Channel clients initialize an unknown message box at PTS 1. Reserve that
|
||||
// state without emitting an event so the real create service message is 2/1.
|
||||
s.ptsSeq[channelID] = domain.InitialChannelPts
|
||||
s.retention[channelID] = domain.ChannelUpdateRetentionCheckpoint{
|
||||
ChannelID: channelID,
|
||||
RetainedThroughPts: domain.InitialChannelPts,
|
||||
}
|
||||
s.invites[inviteHash] = domain.ChannelInvite{
|
||||
ChannelID: channelID,
|
||||
InviteID: inviteID,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ package memory
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) GetParticipants(_ context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||
|
|
@ -892,6 +895,45 @@ func (s *ChannelStore) FilterActiveChannelMemberIDs(_ context.Context, channelID
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) FilterActiveChannelMemberPairs(_ context.Context, userIDsByChannel map[int64][]int64) (map[int64][]int64, error) {
|
||||
requested := make(map[int64][]int64)
|
||||
seen := make(map[[2]int64]struct{})
|
||||
for channelID, userIDs := range userIDsByChannel {
|
||||
if channelID == 0 {
|
||||
continue
|
||||
}
|
||||
for _, userID := range userIDs {
|
||||
if userID == 0 {
|
||||
continue
|
||||
}
|
||||
pair := [2]int64{channelID, userID}
|
||||
if _, ok := seen[pair]; ok {
|
||||
continue
|
||||
}
|
||||
if len(seen) >= store.MaxActiveChannelMemberPairs {
|
||||
return nil, fmt.Errorf("%w: maximum %d", store.ErrActiveChannelMemberPairsLimit, store.MaxActiveChannelMemberPairs)
|
||||
}
|
||||
seen[pair] = struct{}{}
|
||||
requested[channelID] = append(requested[channelID], userID)
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make(map[int64][]int64, len(requested))
|
||||
for channelID, userIDs := range requested {
|
||||
members := s.members[channelID]
|
||||
for _, userID := range userIDs {
|
||||
member, ok := members[userID]
|
||||
if ok && member.Status == domain.ChannelMemberActive {
|
||||
out[channelID] = append(out[channelID], userID)
|
||||
}
|
||||
}
|
||||
sort.Slice(out[channelID], func(i, j int) bool { return out[channelID][i] < out[channelID][j] })
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) FilterChannelMessageAudienceIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
|
|
|||
60
internal/store/memory/channel_members_sparse_test.go
Normal file
60
internal/store/memory/channel_members_sparse_test.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestFilterActiveChannelMemberPairsKeepsExactEdges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channels := NewChannelStore()
|
||||
first, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
MemberUserIDs: []int64{11, 12},
|
||||
Title: "first",
|
||||
Megagroup: true,
|
||||
Date: 1700000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel(first): %v", err)
|
||||
}
|
||||
second, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 2,
|
||||
MemberUserIDs: []int64{11, 12},
|
||||
Title: "second",
|
||||
Megagroup: true,
|
||||
Date: 1700000001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel(second): %v", err)
|
||||
}
|
||||
|
||||
got, err := channels.FilterActiveChannelMemberPairs(ctx, map[int64][]int64{
|
||||
first.Channel.ID: {11},
|
||||
second.Channel.ID: {12},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FilterActiveChannelMemberPairs: %v", err)
|
||||
}
|
||||
if len(got[first.Channel.ID]) != 1 || got[first.Channel.ID][0] != 11 {
|
||||
t.Fatalf("first channel result = %+v, want [11]", got[first.Channel.ID])
|
||||
}
|
||||
if len(got[second.Channel.ID]) != 1 || got[second.Channel.ID][0] != 12 {
|
||||
t.Fatalf("second channel result = %+v, want [12]", got[second.Channel.ID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterActiveChannelMemberPairsRejectsOverLimit(t *testing.T) {
|
||||
userIDs := make([]int64, store.MaxActiveChannelMemberPairs+1)
|
||||
for i := range userIDs {
|
||||
userIDs[i] = int64(i + 1)
|
||||
}
|
||||
_, err := NewChannelStore().FilterActiveChannelMemberPairs(context.Background(), map[int64][]int64{1: userIDs})
|
||||
if !errors.Is(err, store.ErrActiveChannelMemberPairsLimit) {
|
||||
t.Fatalf("FilterActiveChannelMemberPairs error = %v, want ErrActiveChannelMemberPairsLimit", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package memory
|
|||
import (
|
||||
"context"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) {
|
||||
|
|
@ -43,16 +44,25 @@ func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.Chan
|
|||
s.msgViews[req.ChannelID] = make(map[int]int)
|
||||
}
|
||||
if s.msgViewers[req.ChannelID] == nil {
|
||||
s.msgViewers[req.ChannelID] = make(map[int]map[int64]struct{})
|
||||
s.msgViewers[req.ChannelID] = make(map[int]map[int64]int)
|
||||
}
|
||||
viewedAt := req.Date
|
||||
if viewedAt <= 0 {
|
||||
viewedAt = int(time.Now().Unix())
|
||||
}
|
||||
for id := range visible {
|
||||
if req.Increment {
|
||||
if s.msgViewers[req.ChannelID][id] == nil {
|
||||
s.msgViewers[req.ChannelID][id] = make(map[int64]struct{})
|
||||
s.msgViewers[req.ChannelID][id] = make(map[int64]int)
|
||||
}
|
||||
if _, seen := s.msgViewers[req.ChannelID][id][req.UserID]; !seen {
|
||||
s.msgViewers[req.ChannelID][id][req.UserID] = struct{}{}
|
||||
s.msgViewers[req.ChannelID][id][req.UserID] = viewedAt
|
||||
s.msgViews[req.ChannelID][id]++
|
||||
if idx, ok := s.findMessageIndexLocked(req.ChannelID, id); ok {
|
||||
msg := s.messages[req.ChannelID][idx]
|
||||
msg.ViewsCount = s.msgViews[req.ChannelID][id]
|
||||
s.messages[req.ChannelID][idx] = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ func (s *ChannelStore) SetChannelEmojiStatusAdmin(_ context.Context, channelID i
|
|||
}
|
||||
|
||||
func (s *ChannelStore) SetChannelPhotoAdmin(_ context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
|
||||
if channelID == 0 {
|
||||
if channelID == 0 || photo.ID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
|
|
|
|||
392
internal/store/memory/channel_stats.go
Normal file
392
internal/store/memory/channel_stats.go
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) GetChannelStats(_ context.Context, req domain.ChannelStatsRequest) (domain.ChannelStats, error) {
|
||||
if req.ViewerUserID == 0 || req.ChannelID == 0 || !req.Period.Valid() {
|
||||
return domain.ChannelStats{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, _, err := s.statsAdminChannelLocked(req.ViewerUserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelStats{}, err
|
||||
}
|
||||
|
||||
stats := domain.ChannelStats{Channel: cloneChannel(channel), Period: req.Period}
|
||||
days, dayIndex := newMemoryStatsDays(req.Period)
|
||||
prevMin := req.Period.PreviousMinDate()
|
||||
var currentMessages, previousMessages int
|
||||
var currentViews, previousViews int
|
||||
currentPosters := make(map[int64]struct{})
|
||||
previousPosters := make(map[int64]struct{})
|
||||
currentMessageDay := make(map[int]int)
|
||||
previousMessageIDs := make(map[int]struct{})
|
||||
currentMessageIDs := make(map[int]struct{})
|
||||
top := make(map[int64]struct{ messages, chars int })
|
||||
|
||||
for _, member := range s.members[req.ChannelID] {
|
||||
if memoryStatsMemberActiveAt(member, req.Period.MaxDate-1) {
|
||||
stats.Members.Current++
|
||||
}
|
||||
if memoryStatsMemberActiveAt(member, req.Period.MinDate-1) {
|
||||
stats.Members.Previous++
|
||||
}
|
||||
if i, ok := dayIndex[memoryStatsDay(member.JoinedAt)]; ok && member.JoinedAt >= req.Period.MinDate && member.JoinedAt < req.Period.MaxDate {
|
||||
days[i].NewMembers++
|
||||
}
|
||||
}
|
||||
for i := range days {
|
||||
at := days[i].Date + 86400 - 1
|
||||
if at >= req.Period.MaxDate {
|
||||
at = req.Period.MaxDate - 1
|
||||
}
|
||||
for _, member := range s.members[req.ChannelID] {
|
||||
if memoryStatsMemberActiveAt(member, at) {
|
||||
days[i].Members++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, msg := range s.messages[req.ChannelID] {
|
||||
if msg.Deleted || msg.Action != nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case msg.Date >= req.Period.MinDate && msg.Date < req.Period.MaxDate:
|
||||
currentMessages++
|
||||
currentViews += msg.ViewsCount
|
||||
currentPosters[msg.SenderUserID] = struct{}{}
|
||||
currentMessageIDs[msg.ID] = struct{}{}
|
||||
if i, ok := dayIndex[memoryStatsDay(msg.Date)]; ok {
|
||||
currentMessageDay[msg.ID] = i
|
||||
days[i].Messages++
|
||||
days[i].Views += msg.ViewsCount
|
||||
}
|
||||
entry := top[msg.SenderUserID]
|
||||
entry.messages++
|
||||
entry.chars += utf8.RuneCountInString(msg.Body)
|
||||
top[msg.SenderUserID] = entry
|
||||
case msg.Date >= prevMin && msg.Date < req.Period.MinDate:
|
||||
previousMessages++
|
||||
previousViews += msg.ViewsCount
|
||||
previousPosters[msg.SenderUserID] = struct{}{}
|
||||
previousMessageIDs[msg.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
currentViewerIDs := make(map[int64]struct{})
|
||||
previousViewerIDs := make(map[int64]struct{})
|
||||
dayViewerIDs := make(map[int]map[int64]struct{}, len(days))
|
||||
for _, viewers := range s.msgViewers[req.ChannelID] {
|
||||
for userID, viewedAt := range viewers {
|
||||
switch {
|
||||
case viewedAt >= req.Period.MinDate && viewedAt < req.Period.MaxDate:
|
||||
currentViewerIDs[userID] = struct{}{}
|
||||
if i, ok := dayIndex[memoryStatsDay(viewedAt)]; ok {
|
||||
if dayViewerIDs[i] == nil {
|
||||
dayViewerIDs[i] = make(map[int64]struct{})
|
||||
}
|
||||
dayViewerIDs[i][userID] = struct{}{}
|
||||
}
|
||||
case viewedAt >= prevMin && viewedAt < req.Period.MinDate:
|
||||
previousViewerIDs[userID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var currentReactions, previousReactions int
|
||||
for messageID, byUser := range s.reactions[req.ChannelID] {
|
||||
_, current := currentMessageIDs[messageID]
|
||||
_, previous := previousMessageIDs[messageID]
|
||||
for _, rows := range byUser {
|
||||
for _, row := range rows {
|
||||
if current {
|
||||
currentReactions++
|
||||
if i, ok := currentMessageDay[messageID]; ok {
|
||||
days[i].Reactions++
|
||||
addMemoryStatsReaction(&days[i], row.Reaction)
|
||||
}
|
||||
} else if previous {
|
||||
previousReactions++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
forwardCounts := s.publicForwardCountsLocked(req.ChannelID)
|
||||
currentShares, previousShares := 0, 0
|
||||
for messageID, count := range forwardCounts {
|
||||
if _, ok := currentMessageIDs[messageID]; ok {
|
||||
currentShares += count
|
||||
if i, ok := currentMessageDay[messageID]; ok {
|
||||
days[i].Shares += count
|
||||
}
|
||||
} else if _, ok := previousMessageIDs[messageID]; ok {
|
||||
previousShares += count
|
||||
}
|
||||
}
|
||||
|
||||
for i := range days {
|
||||
days[i].Viewers = len(dayViewerIDs[i])
|
||||
posters := make(map[int64]struct{})
|
||||
start, end := days[i].Date, days[i].Date+86400
|
||||
for _, msg := range s.messages[req.ChannelID] {
|
||||
if !msg.Deleted && msg.Action == nil && msg.Date >= start && msg.Date < end {
|
||||
posters[msg.SenderUserID] = struct{}{}
|
||||
}
|
||||
}
|
||||
days[i].Posters = len(posters)
|
||||
sort.Slice(days[i].ByReaction, func(a, b int) bool {
|
||||
return days[i].ByReaction[a].Reaction.Key() < days[i].ByReaction[b].Reaction.Key()
|
||||
})
|
||||
}
|
||||
|
||||
stats.Messages = domain.StatsValueAndPrev{Current: float64(currentMessages), Previous: float64(previousMessages)}
|
||||
stats.Viewers = domain.StatsValueAndPrev{Current: float64(len(currentViewerIDs)), Previous: float64(len(previousViewerIDs))}
|
||||
stats.Posters = domain.StatsValueAndPrev{Current: float64(len(currentPosters)), Previous: float64(len(previousPosters))}
|
||||
stats.ViewsPerPost = memoryStatsAverage(currentViews, currentMessages, previousViews, previousMessages)
|
||||
stats.SharesPerPost = memoryStatsAverage(currentShares, currentMessages, previousShares, previousMessages)
|
||||
stats.ReactionsPerPost = memoryStatsAverage(currentReactions, currentMessages, previousReactions, previousMessages)
|
||||
stats.Days = days
|
||||
|
||||
for userID, entry := range top {
|
||||
if userID == 0 || entry.messages == 0 {
|
||||
continue
|
||||
}
|
||||
stats.TopPosters = append(stats.TopPosters, domain.ChannelStatsTopPoster{
|
||||
UserID: userID, Messages: entry.messages, AvgChars: entry.chars / entry.messages,
|
||||
})
|
||||
}
|
||||
sort.Slice(stats.TopPosters, func(i, j int) bool {
|
||||
if stats.TopPosters[i].Messages != stats.TopPosters[j].Messages {
|
||||
return stats.TopPosters[i].Messages > stats.TopPosters[j].Messages
|
||||
}
|
||||
return stats.TopPosters[i].UserID < stats.TopPosters[j].UserID
|
||||
})
|
||||
if len(stats.TopPosters) > domain.MaxChannelStatsTopPosters {
|
||||
stats.TopPosters = stats.TopPosters[:domain.MaxChannelStatsTopPosters]
|
||||
}
|
||||
|
||||
messages := append([]domain.ChannelMessage(nil), s.messages[req.ChannelID]...)
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
if messages[i].Date != messages[j].Date {
|
||||
return messages[i].Date > messages[j].Date
|
||||
}
|
||||
return messages[i].ID > messages[j].ID
|
||||
})
|
||||
for _, msg := range messages {
|
||||
if msg.Deleted || msg.Action != nil {
|
||||
continue
|
||||
}
|
||||
stats.RecentPosts = append(stats.RecentPosts, domain.ChannelStatsRecentPost{
|
||||
MessageID: msg.ID,
|
||||
Views: msg.ViewsCount,
|
||||
Forwards: forwardCounts[msg.ID],
|
||||
Reactions: memoryStatsReactionCount(s.reactions[req.ChannelID][msg.ID]),
|
||||
})
|
||||
if len(stats.RecentPosts) == domain.MaxChannelStatsRecentPosts {
|
||||
break
|
||||
}
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetChannelMessageStats(_ context.Context, req domain.ChannelMessageStatsRequest) (domain.ChannelMessageStats, error) {
|
||||
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
|
||||
req.MessageID > domain.MaxMessageBoxID || !req.Period.Valid() {
|
||||
return domain.ChannelMessageStats{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, _, err := s.statsAdminChannelLocked(req.ViewerUserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageStats{}, err
|
||||
}
|
||||
message, ok := s.findMessageLocked(req.ChannelID, req.MessageID)
|
||||
if !ok || message.Deleted {
|
||||
return domain.ChannelMessageStats{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
days, dayIndex := newMemoryStatsDays(req.Period)
|
||||
for _, viewedAt := range s.msgViewers[req.ChannelID][req.MessageID] {
|
||||
if i, ok := dayIndex[memoryStatsDay(viewedAt)]; ok && viewedAt >= req.Period.MinDate && viewedAt < req.Period.MaxDate {
|
||||
days[i].Views++
|
||||
}
|
||||
}
|
||||
for _, rows := range s.reactions[req.ChannelID][req.MessageID] {
|
||||
for _, row := range rows {
|
||||
if i, ok := dayIndex[memoryStatsDay(row.Date)]; ok && row.Date >= req.Period.MinDate && row.Date < req.Period.MaxDate {
|
||||
days[i].Reactions++
|
||||
addMemoryStatsReaction(&days[i], row.Reaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range days {
|
||||
sort.Slice(days[i].ByReaction, func(a, b int) bool {
|
||||
return days[i].ByReaction[a].Reaction.Key() < days[i].ByReaction[b].Reaction.Key()
|
||||
})
|
||||
}
|
||||
return domain.ChannelMessageStats{
|
||||
Channel: cloneChannel(channel), Message: cloneChannelMessage(message), Period: req.Period, Days: days,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListChannelMessagePublicForwards(_ context.Context, req domain.ChannelMessagePublicForwardListRequest) (domain.ChannelMessagePublicForwardList, error) {
|
||||
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
|
||||
req.MessageID > domain.MaxMessageBoxID || req.Limit <= 0 || req.Limit > domain.MaxChannelMessagePublicForwards {
|
||||
return domain.ChannelMessagePublicForwardList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
cursor, err := domain.ParseChannelMessagePublicForwardCursor(req.Offset)
|
||||
if err != nil {
|
||||
return domain.ChannelMessagePublicForwardList{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, _, err := s.statsAdminChannelLocked(req.ViewerUserID, req.ChannelID); err != nil {
|
||||
return domain.ChannelMessagePublicForwardList{}, err
|
||||
}
|
||||
source, ok := s.findMessageLocked(req.ChannelID, req.MessageID)
|
||||
if !ok || source.Deleted {
|
||||
return domain.ChannelMessagePublicForwardList{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
all := make([]domain.ChannelMessage, 0)
|
||||
for channelID, channel := range s.channels {
|
||||
if !memoryStatsPublicChannel(channel) {
|
||||
continue
|
||||
}
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if memoryStatsForwardsPost(msg, req.ChannelID, req.MessageID) {
|
||||
all = append(all, cloneChannelMessage(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return memoryStatsForwardBefore(all[i], all[j]) })
|
||||
page := make([]domain.ChannelMessage, 0, req.Limit)
|
||||
for _, msg := range all {
|
||||
if cursor.Date != 0 && !memoryStatsForwardAfterCursor(msg, cursor) {
|
||||
continue
|
||||
}
|
||||
page = append(page, msg)
|
||||
if len(page) == req.Limit+1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
next := ""
|
||||
if len(page) > req.Limit {
|
||||
page = page[:req.Limit]
|
||||
next = domain.FormatChannelMessagePublicForwardCursor(page[len(page)-1])
|
||||
}
|
||||
return domain.ChannelMessagePublicForwardList{Count: len(all), Messages: page, NextOffset: next}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) statsAdminChannelLocked(userID, channelID int64) (domain.Channel, domain.ChannelMember, error) {
|
||||
channel, member, err := s.channelAndMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, domain.ChannelMember{}, err
|
||||
}
|
||||
if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin {
|
||||
return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
return channel, member, nil
|
||||
}
|
||||
|
||||
func newMemoryStatsDays(period domain.StatsPeriod) ([]domain.ChannelStatsDay, map[int]int) {
|
||||
start := memoryStatsDay(period.MinDate)
|
||||
end := memoryStatsDay(period.MaxDate - 1)
|
||||
days := make([]domain.ChannelStatsDay, 0, (end-start)/86400+1)
|
||||
index := make(map[int]int)
|
||||
for date := start; date <= end; date += 86400 {
|
||||
index[date] = len(days)
|
||||
days = append(days, domain.ChannelStatsDay{Date: date})
|
||||
}
|
||||
return days, index
|
||||
}
|
||||
|
||||
func memoryStatsDay(date int) int {
|
||||
if date <= 0 {
|
||||
return 0
|
||||
}
|
||||
return date - date%86400
|
||||
}
|
||||
|
||||
func memoryStatsMemberActiveAt(member domain.ChannelMember, at int) bool {
|
||||
return member.JoinedAt > 0 && member.JoinedAt <= at && (member.LeftAt == 0 || member.LeftAt > at)
|
||||
}
|
||||
|
||||
func memoryStatsAverage(current, currentCount, previous, previousCount int) domain.StatsValueAndPrev {
|
||||
var out domain.StatsValueAndPrev
|
||||
if currentCount > 0 {
|
||||
out.Current = float64(current) / float64(currentCount)
|
||||
}
|
||||
if previousCount > 0 {
|
||||
out.Previous = float64(previous) / float64(previousCount)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addMemoryStatsReaction(day *domain.ChannelStatsDay, reaction domain.MessageReaction) {
|
||||
for i := range day.ByReaction {
|
||||
if day.ByReaction[i].Reaction.Key() == reaction.Key() {
|
||||
day.ByReaction[i].Count++
|
||||
return
|
||||
}
|
||||
}
|
||||
day.ByReaction = append(day.ByReaction, domain.StatsReactionCount{Reaction: reaction, Count: 1})
|
||||
}
|
||||
|
||||
func memoryStatsReactionCount(byUser map[int64][]domain.ChannelMessagePeerReaction) int {
|
||||
count := 0
|
||||
for _, rows := range byUser {
|
||||
count += len(rows)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *ChannelStore) publicForwardCountsLocked(sourceChannelID int64) map[int]int {
|
||||
counts := make(map[int]int)
|
||||
for channelID, channel := range s.channels {
|
||||
if !memoryStatsPublicChannel(channel) {
|
||||
continue
|
||||
}
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if msg.Deleted || msg.Forward == nil || msg.Forward.From.Type != domain.PeerTypeChannel ||
|
||||
msg.Forward.From.ID != sourceChannelID || msg.Forward.ChannelPost <= 0 {
|
||||
continue
|
||||
}
|
||||
counts[msg.Forward.ChannelPost]++
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func memoryStatsPublicChannel(channel domain.Channel) bool {
|
||||
return !channel.Deleted && strings.TrimSpace(channel.Username) != "" && (channel.Broadcast || channel.Megagroup)
|
||||
}
|
||||
|
||||
func memoryStatsForwardsPost(msg domain.ChannelMessage, channelID int64, messageID int) bool {
|
||||
return !msg.Deleted && msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeChannel &&
|
||||
msg.Forward.From.ID == channelID && msg.Forward.ChannelPost == messageID
|
||||
}
|
||||
|
||||
func memoryStatsForwardBefore(a, b domain.ChannelMessage) bool {
|
||||
if a.Date != b.Date {
|
||||
return a.Date > b.Date
|
||||
}
|
||||
if a.ChannelID != b.ChannelID {
|
||||
return a.ChannelID < b.ChannelID
|
||||
}
|
||||
return a.ID > b.ID
|
||||
}
|
||||
|
||||
func memoryStatsForwardAfterCursor(msg domain.ChannelMessage, cursor domain.ChannelMessagePublicForwardCursor) bool {
|
||||
return msg.Date < cursor.Date ||
|
||||
(msg.Date == cursor.Date && (msg.ChannelID > cursor.ChannelID ||
|
||||
(msg.ChannelID == cursor.ChannelID && msg.ID < cursor.MessageID)))
|
||||
}
|
||||
133
internal/store/memory/channel_stats_test.go
Normal file
133
internal/store/memory/channel_stats_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelStatsUseDurableFactsAndPagePublicForwards(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
const owner, viewer int64 = 1, 2
|
||||
period := domain.StatsPeriod{MinDate: 1_700_006_400, MaxDate: 1_700_611_200}
|
||||
|
||||
source, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner,
|
||||
Title: "stats source",
|
||||
Broadcast: true,
|
||||
MemberUserIDs: []int64{viewer},
|
||||
Date: period.MinDate - 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create source: %v", err)
|
||||
}
|
||||
previous, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner, ChannelID: source.Channel.ID, RandomID: 100, Message: "previous", Date: period.MinDate - 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send previous: %v", err)
|
||||
}
|
||||
_ = previous
|
||||
post, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner, ChannelID: source.Channel.ID, RandomID: 101, Message: "current post", Date: period.MinDate + 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send current: %v", err)
|
||||
}
|
||||
if _, err := store.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
|
||||
UserID: viewer, ChannelID: source.Channel.ID, IDs: []int{post.Message.ID}, Increment: true, Date: period.MinDate + 20,
|
||||
}); err != nil {
|
||||
t.Fatalf("increment view: %v", err)
|
||||
}
|
||||
reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "👍"}
|
||||
if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
|
||||
UserID: viewer, ChannelID: source.Channel.ID, MessageID: post.Message.ID, Reactions: []domain.MessageReaction{reaction}, Date: period.MinDate + 30,
|
||||
}); err != nil {
|
||||
t.Fatalf("react: %v", err)
|
||||
}
|
||||
|
||||
publicCreated, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner, Title: "public destination", Broadcast: true, Date: period.MinDate + 40,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create public destination: %v", err)
|
||||
}
|
||||
publicChannel, err := store.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
|
||||
UserID: owner, ChannelID: publicCreated.Channel.ID, Username: "stats_forward_memory",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("make public destination: %v", err)
|
||||
}
|
||||
privateChannel, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner, Title: "private destination", Broadcast: true, Date: period.MinDate + 40,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create private destination: %v", err)
|
||||
}
|
||||
forward := &domain.MessageForward{
|
||||
From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.Channel.ID}, Date: post.Message.Date, ChannelPost: post.Message.ID,
|
||||
}
|
||||
for i, date := range []int{period.MinDate + 50, period.MinDate + 60} {
|
||||
if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner, ChannelID: publicChannel.ID, RandomID: int64(200 + i), Message: "public forward", Forward: forward, Date: date,
|
||||
}); err != nil {
|
||||
t.Fatalf("send public forward %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner, ChannelID: privateChannel.Channel.ID, RandomID: 300, Message: "private forward", Forward: forward, Date: period.MinDate + 70,
|
||||
}); err != nil {
|
||||
t.Fatalf("send private forward: %v", err)
|
||||
}
|
||||
|
||||
stats, err := store.GetChannelStats(ctx, domain.ChannelStatsRequest{
|
||||
ViewerUserID: owner, ChannelID: source.Channel.ID, Period: period,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get stats: %v", err)
|
||||
}
|
||||
if stats.Members.Current != 2 || stats.Messages.Current != 1 || stats.Messages.Previous != 1 ||
|
||||
stats.Viewers.Current != 1 || stats.Posters.Current != 1 || stats.ViewsPerPost.Current != 1 ||
|
||||
stats.SharesPerPost.Current != 2 || stats.ReactionsPerPost.Current != 1 {
|
||||
t.Fatalf("stats = %+v, want durable current/previous aggregates", stats)
|
||||
}
|
||||
if len(stats.Days) == 0 || len(stats.Days[0].ByReaction) != 1 || stats.Days[0].Shares != 2 {
|
||||
t.Fatalf("stats days = %+v, want view/share/reaction buckets", stats.Days)
|
||||
}
|
||||
messageStats, err := store.GetChannelMessageStats(ctx, domain.ChannelMessageStatsRequest{
|
||||
ViewerUserID: owner, ChannelID: source.Channel.ID, MessageID: post.Message.ID, Period: period,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get message stats: %v", err)
|
||||
}
|
||||
if len(messageStats.Days) == 0 || messageStats.Days[0].Views != 1 || messageStats.Days[0].Reactions != 1 {
|
||||
t.Fatalf("message stats days = %+v, want one view and reaction", messageStats.Days)
|
||||
}
|
||||
|
||||
first, err := store.ListChannelMessagePublicForwards(ctx, domain.ChannelMessagePublicForwardListRequest{
|
||||
ViewerUserID: owner, ChannelID: source.Channel.ID, MessageID: post.Message.ID, Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list first forward page: %v", err)
|
||||
}
|
||||
if first.Count != 2 || len(first.Messages) != 1 || first.NextOffset == "" || first.Messages[0].ChannelID != publicChannel.ID {
|
||||
t.Fatalf("first forward page = %+v, want one of two public forwards", first)
|
||||
}
|
||||
second, err := store.ListChannelMessagePublicForwards(ctx, domain.ChannelMessagePublicForwardListRequest{
|
||||
ViewerUserID: owner, ChannelID: source.Channel.ID, MessageID: post.Message.ID, Offset: first.NextOffset, Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list second forward page: %v", err)
|
||||
}
|
||||
if second.Count != 2 || len(second.Messages) != 1 || second.Messages[0].ID == first.Messages[0].ID || second.NextOffset != "" {
|
||||
t.Fatalf("second forward page = %+v, want remaining public forward", second)
|
||||
}
|
||||
if _, err := store.ListChannelMessagePublicForwards(ctx, domain.ChannelMessagePublicForwardListRequest{
|
||||
ViewerUserID: owner, ChannelID: source.Channel.ID, MessageID: post.Message.ID, Offset: "bad", Limit: 1,
|
||||
}); !errors.Is(err, domain.ErrStatsOffsetInvalid) {
|
||||
t.Fatalf("invalid cursor err = %v, want ErrStatsOffsetInvalid", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -63,20 +63,23 @@ func (w channelReadWatermark) advance(userID int64, maxID int) channelReadWaterm
|
|||
|
||||
// ChannelStore is an in-memory channel/supergroup store for tests and local development.
|
||||
type ChannelStore struct {
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
nextHash int64
|
||||
channels map[int64]domain.Channel
|
||||
members map[int64]map[int64]domain.ChannelMember
|
||||
dialogs map[int64]map[int64]domain.ChannelDialog
|
||||
topics map[int64]map[int]domain.ChannelForumTopic
|
||||
messages map[int64][]domain.ChannelMessage
|
||||
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
|
||||
top map[int64]map[string]domain.TopMessageReaction
|
||||
recent map[int64]map[string]domain.RecentMessageReaction
|
||||
mentions map[int64]map[int64]map[int]memoryMention
|
||||
msgViews map[int64]map[int]int
|
||||
msgViewers map[int64]map[int]map[int64]struct{}
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
nextHash int64
|
||||
channels map[int64]domain.Channel
|
||||
members map[int64]map[int64]domain.ChannelMember
|
||||
dialogs map[int64]map[int64]domain.ChannelDialog
|
||||
topics map[int64]map[int]domain.ChannelForumTopic
|
||||
messages map[int64][]domain.ChannelMessage
|
||||
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
|
||||
top map[int64]map[string]domain.TopMessageReaction
|
||||
recent map[int64]map[string]domain.RecentMessageReaction
|
||||
mentions map[int64]map[int64]map[int]memoryMention
|
||||
msgViews map[int64]map[int]int
|
||||
// msgViewers stores the first durable view time for each unique viewer.
|
||||
// Keeping the timestamp (instead of only a set membership bit) lets stats
|
||||
// produce real event-time graphs while preserving idempotent view counts.
|
||||
msgViewers map[int64]map[int]map[int64]int
|
||||
events map[int64][]domain.ChannelUpdateEvent
|
||||
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
|
||||
// historyClearDates is the no-PTS recovery timestamp for a future
|
||||
|
|
@ -136,7 +139,7 @@ func NewChannelStore() *ChannelStore {
|
|||
recent: make(map[int64]map[string]domain.RecentMessageReaction),
|
||||
mentions: make(map[int64]map[int64]map[int]memoryMention),
|
||||
msgViews: make(map[int64]map[int]int),
|
||||
msgViewers: make(map[int64]map[int]map[int64]struct{}),
|
||||
msgViewers: make(map[int64]map[int]map[int64]int),
|
||||
events: make(map[int64][]domain.ChannelUpdateEvent),
|
||||
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
|
||||
historyClearDates: make(map[int64]map[int64]int),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,46 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelCreateInitialPtsBaseline(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
Title: "initial pts",
|
||||
Megagroup: true,
|
||||
Date: 1_700_000_080,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
if created.Channel.Pts != domain.FirstChannelEventPts || created.Message.Pts != domain.FirstChannelEventPts ||
|
||||
created.Event.Pts != domain.FirstChannelEventPts || created.Event.PtsCount != 1 {
|
||||
t.Fatalf("create result = channel:%+v message:%+v event:%+v, want first event 2/1", created.Channel, created.Message, created.Event)
|
||||
}
|
||||
checkpoint := store.retention[created.Channel.ID]
|
||||
if checkpoint.RetainedThroughPts != domain.InitialChannelPts || checkpoint.LatestPts != domain.FirstChannelEventPts {
|
||||
t.Fatalf("checkpoint = %+v, want floor/latest 1/2", checkpoint)
|
||||
}
|
||||
fromBaseline, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: 1, ChannelID: created.Channel.ID, Pts: domain.InitialChannelPts, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("difference from baseline: %v", err)
|
||||
}
|
||||
if fromBaseline.TooLong || len(fromBaseline.Events) != 1 || fromBaseline.Events[0].Pts != domain.FirstChannelEventPts {
|
||||
t.Fatalf("difference from baseline = %+v, want create event at pts=2", fromBaseline)
|
||||
}
|
||||
fromZero, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: 1, ChannelID: created.Channel.ID, Pts: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("difference from zero: %v", err)
|
||||
}
|
||||
if !fromZero.TooLong || fromZero.Pts != domain.FirstChannelEventPts || len(fromZero.NewMessages) != 1 {
|
||||
t.Fatalf("difference from zero = %+v, want complete snapshot at pts=2", fromZero)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelCreateCreatesPermanentInviteAndHasLink(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ func TestDeleteExpiredChannelUpdateEventsIsBoundedMemory(t *testing.T) {
|
|||
t.Fatalf("deleted = %d, want bounded batch 2", deleted)
|
||||
}
|
||||
checkpoint := store.retention[created.Channel.ID]
|
||||
if checkpoint.RetainedThroughPts != 2 || len(store.events[created.Channel.ID]) != 2 {
|
||||
t.Fatalf("after bounded prune checkpoint=%+v events=%d, want floor=2 and 2 rows", checkpoint, len(store.events[created.Channel.ID]))
|
||||
if checkpoint.RetainedThroughPts != 3 || len(store.events[created.Channel.ID]) != 2 {
|
||||
t.Fatalf("after bounded prune checkpoint=%+v events=%d, want floor=3 and 2 rows", checkpoint, len(store.events[created.Channel.ID]))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,122 @@ func (s *ContactStore) GetReverseContacts(_ context.Context, userID int64, owner
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) ContactProjectionForViewers(_ context.Context, viewerUserIDs, contactUserIDs []int64) (domain.ContactProjectionBatch, error) {
|
||||
out := domain.ContactProjectionBatch{
|
||||
Contacts: make(map[int64]map[int64]domain.Contact, len(viewerUserIDs)),
|
||||
PersonalPhotos: make(map[int64]map[int64]domain.ProfilePhotoRef, len(viewerUserIDs)),
|
||||
}
|
||||
if len(viewerUserIDs) == 0 || len(contactUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
viewers := make(map[int64]struct{}, len(viewerUserIDs))
|
||||
for _, id := range viewerUserIDs {
|
||||
if id != 0 {
|
||||
viewers[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
targets := make(map[int64]struct{}, len(contactUserIDs))
|
||||
for _, id := range contactUserIDs {
|
||||
if id != 0 {
|
||||
targets[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(viewers) == 0 || len(targets) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for viewerID := range viewers {
|
||||
list := s.m[viewerID]
|
||||
for _, contact := range list.Contacts {
|
||||
targetID := contact.User.ID
|
||||
if _, ok := targets[targetID]; !ok {
|
||||
continue
|
||||
}
|
||||
if out.Contacts[viewerID] == nil {
|
||||
out.Contacts[viewerID] = make(map[int64]domain.Contact, len(targets))
|
||||
}
|
||||
out.Contacts[viewerID][targetID] = domain.Contact{
|
||||
User: domain.User{ID: targetID},
|
||||
FirstName: contact.FirstName,
|
||||
LastName: contact.LastName,
|
||||
Phone: contact.Phone,
|
||||
Note: contact.Note,
|
||||
NoteEntities: append([]domain.MessageEntity(nil), contact.NoteEntities...),
|
||||
Mutual: contact.Mutual || contact.User.Mutual,
|
||||
CloseFriend: contact.CloseFriend || contact.User.CloseFriend,
|
||||
}
|
||||
if contact.User.PhotoID == 0 {
|
||||
continue
|
||||
}
|
||||
if out.PersonalPhotos[viewerID] == nil {
|
||||
out.PersonalPhotos[viewerID] = make(map[int64]domain.ProfilePhotoRef, len(targets))
|
||||
}
|
||||
out.PersonalPhotos[viewerID][targetID] = cloneProfilePhotoRef(domain.ProfilePhotoRef{
|
||||
PhotoID: contact.User.PhotoID,
|
||||
DCID: contact.User.PhotoDCID,
|
||||
Stripped: contact.User.PhotoStripped,
|
||||
Personal: true,
|
||||
HasVideo: contact.User.PhotoHasVideo,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) ContactProjectionForViewerUserIDs(_ context.Context, contactUserIDsByViewer map[int64][]int64) (domain.ContactProjectionBatch, error) {
|
||||
out := domain.ContactProjectionBatch{
|
||||
Contacts: make(map[int64]map[int64]domain.Contact, len(contactUserIDsByViewer)),
|
||||
PersonalPhotos: make(map[int64]map[int64]domain.ProfilePhotoRef, len(contactUserIDsByViewer)),
|
||||
}
|
||||
if len(contactUserIDsByViewer) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for viewerID, contactUserIDs := range contactUserIDsByViewer {
|
||||
if viewerID == 0 || len(contactUserIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
want := make(map[int64]struct{}, len(contactUserIDs))
|
||||
for _, id := range contactUserIDs {
|
||||
if id != 0 {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, contact := range s.m[viewerID].Contacts {
|
||||
targetID := contact.User.ID
|
||||
if _, ok := want[targetID]; !ok {
|
||||
continue
|
||||
}
|
||||
if out.Contacts[viewerID] == nil {
|
||||
out.Contacts[viewerID] = make(map[int64]domain.Contact, len(want))
|
||||
}
|
||||
out.Contacts[viewerID][targetID] = domain.Contact{
|
||||
User: domain.User{ID: targetID},
|
||||
FirstName: contact.FirstName,
|
||||
LastName: contact.LastName,
|
||||
Phone: contact.Phone,
|
||||
Note: contact.Note,
|
||||
NoteEntities: append([]domain.MessageEntity(nil), contact.NoteEntities...),
|
||||
Mutual: contact.Mutual || contact.User.Mutual,
|
||||
CloseFriend: contact.CloseFriend || contact.User.CloseFriend,
|
||||
}
|
||||
if contact.User.PhotoID == 0 {
|
||||
continue
|
||||
}
|
||||
if out.PersonalPhotos[viewerID] == nil {
|
||||
out.PersonalPhotos[viewerID] = make(map[int64]domain.ProfilePhotoRef, len(want))
|
||||
}
|
||||
out.PersonalPhotos[viewerID][targetID] = cloneProfilePhotoRef(domain.ProfilePhotoRef{
|
||||
PhotoID: contact.User.PhotoID, DCID: contact.User.PhotoDCID,
|
||||
Stripped: contact.User.PhotoStripped, Personal: true, HasVideo: contact.User.PhotoHasVideo,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||
contact := domain.Contact{
|
||||
User: domain.User{
|
||||
|
|
@ -391,9 +507,15 @@ func cloneContacts(contacts []domain.Contact) []domain.Contact {
|
|||
|
||||
func cloneContact(contact domain.Contact) domain.Contact {
|
||||
contact.NoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...)
|
||||
contact.User.PhotoStripped = append([]byte(nil), contact.User.PhotoStripped...)
|
||||
return contact
|
||||
}
|
||||
|
||||
func cloneProfilePhotoRef(ref domain.ProfilePhotoRef) domain.ProfilePhotoRef {
|
||||
ref.Stripped = append([]byte(nil), ref.Stripped...)
|
||||
return ref
|
||||
}
|
||||
|
||||
func contactListHash(contacts []domain.Contact) int64 {
|
||||
if len(contacts) == 0 {
|
||||
return 0
|
||||
|
|
|
|||
92
internal/store/memory/contacts_sparse_test.go
Normal file
92
internal/store/memory/contacts_sparse_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestContactProjectionForViewerUserIDsDoesNotCrossPairs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
contacts := NewContactStore()
|
||||
const (
|
||||
viewerA = int64(11)
|
||||
viewerB = int64(12)
|
||||
ownerA = int64(21)
|
||||
ownerB = int64(22)
|
||||
)
|
||||
for _, row := range []struct {
|
||||
viewer int64
|
||||
owner int64
|
||||
name string
|
||||
photo int64
|
||||
}{
|
||||
{viewerA, ownerA, "A expected", 101},
|
||||
{viewerA, ownerB, "B cross", 102},
|
||||
{viewerB, ownerA, "A cross", 103},
|
||||
{viewerB, ownerB, "B expected", 104},
|
||||
} {
|
||||
if _, err := contacts.Upsert(ctx, row.viewer, domain.ContactInput{
|
||||
ContactUserID: row.owner,
|
||||
FirstName: row.name,
|
||||
Phone: "known-phone",
|
||||
Note: "private note",
|
||||
NoteEntities: []domain.MessageEntity{{
|
||||
Type: domain.MessageEntityBold, Length: 7,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := contacts.SetPersonalPhoto(ctx, row.viewer, row.owner, row.photo, 1); err != nil || !found {
|
||||
t.Fatalf("SetPersonalPhoto %d->%d: found=%v err=%v", row.viewer, row.owner, found, err)
|
||||
}
|
||||
}
|
||||
got, err := contacts.ContactProjectionForViewerUserIDs(ctx, map[int64][]int64{
|
||||
viewerA: {ownerA},
|
||||
viewerB: {ownerB},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Contacts[viewerA]) != 1 || got.Contacts[viewerA][ownerA].FirstName != "A expected" {
|
||||
t.Fatalf("viewer A contacts = %+v", got.Contacts[viewerA])
|
||||
}
|
||||
contactA := got.Contacts[viewerA][ownerA]
|
||||
if !reflect.DeepEqual(contactA.User, domain.User{ID: ownerA}) {
|
||||
t.Fatalf("viewer A sparse projection retained base user data: %+v", contactA.User)
|
||||
}
|
||||
if contactA.Phone != "known-phone" || contactA.Note != "private note" || len(contactA.NoteEntities) != 1 || contactA.NoteEntities[0].Length != 7 {
|
||||
t.Fatalf("viewer A sparse overlay = %+v", contactA)
|
||||
}
|
||||
if len(got.Contacts[viewerB]) != 1 || got.Contacts[viewerB][ownerB].FirstName != "B expected" {
|
||||
t.Fatalf("viewer B contacts = %+v", got.Contacts[viewerB])
|
||||
}
|
||||
if _, ok := got.Contacts[viewerA][ownerB]; ok {
|
||||
t.Fatal("viewer A unexpectedly received viewer B's requested owner")
|
||||
}
|
||||
if _, ok := got.Contacts[viewerB][ownerA]; ok {
|
||||
t.Fatal("viewer B unexpectedly received viewer A's requested owner")
|
||||
}
|
||||
if len(got.PersonalPhotos[viewerA]) != 1 || got.PersonalPhotos[viewerA][ownerA].PhotoID != 101 {
|
||||
t.Fatalf("viewer A personal photos = %+v", got.PersonalPhotos[viewerA])
|
||||
}
|
||||
if len(got.PersonalPhotos[viewerB]) != 1 || got.PersonalPhotos[viewerB][ownerB].PhotoID != 104 {
|
||||
t.Fatalf("viewer B personal photos = %+v", got.PersonalPhotos[viewerB])
|
||||
}
|
||||
|
||||
// Returned overlay slices are caller-owned, and the personal photo remains
|
||||
// in its dedicated projection map rather than leaking through Contact.User.
|
||||
contactA.NoteEntities[0].Length = 99
|
||||
gotAgain, err := contacts.ContactProjectionForViewerUserIDs(ctx, map[int64][]int64{viewerA: {ownerA}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotAgain.Contacts[viewerA][ownerA].NoteEntities[0].Length != 7 {
|
||||
t.Fatalf("sparse overlay shared NoteEntities with caller: %+v", gotAgain.Contacts[viewerA][ownerA])
|
||||
}
|
||||
if !reflect.DeepEqual(gotAgain.Contacts[viewerA][ownerA].User, domain.User{ID: ownerA}) {
|
||||
t.Fatalf("sparse projection reintroduced base user data: %+v", gotAgain.Contacts[viewerA][ownerA].User)
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +98,39 @@ func (s *DialogStore) ListByPeers(_ context.Context, userID int64, peers []domai
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// ListPrivateDialogPeerIDs returns the bounded private-dialog peer set used by
|
||||
// presence fan-out. Keep this narrow read available in the in-memory
|
||||
// production-shaped fake as well: callers must not fall back to hydrating a
|
||||
// complete dialog page when a store implementation lacks the optimized path.
|
||||
func (s *DialogStore) ListPrivateDialogPeerIDs(_ context.Context, userID int64, limit int) ([]int64, error) {
|
||||
s.mu.RLock()
|
||||
dialogs := cloneDialogs(s.m[userID].Dialogs)
|
||||
s.mu.RUnlock()
|
||||
|
||||
sort.SliceStable(dialogs, func(i, j int) bool {
|
||||
return dialogLess(dialogs[i], dialogs[j])
|
||||
})
|
||||
if limit <= 0 || limit > 4096 {
|
||||
limit = 4096
|
||||
}
|
||||
out := make([]int64, 0, min(limit, len(dialogs)))
|
||||
seen := make(map[int64]struct{}, min(limit, len(dialogs)))
|
||||
for _, dialog := range dialogs {
|
||||
if dialog.Peer.Type != domain.PeerTypeUser || dialog.Peer.ID == 0 || dialog.Peer.ID == userID {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[dialog.Peer.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[dialog.Peer.ID] = struct{}{}
|
||||
out = append(out, dialog.Peer.ID)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveList 保存一份用户会话列表,供测试和本地替身使用。
|
||||
func (s *DialogStore) SaveList(_ context.Context, userID int64, list domain.DialogList) error {
|
||||
list.Dialogs = cloneDialogs(list.Dialogs)
|
||||
|
|
@ -217,6 +250,27 @@ func (s *DialogStore) ListDrafts(_ context.Context, userID int64, limit int) ([]
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) ListDraftsByPeers(_ context.Context, userID int64, peers []domain.Peer) ([]domain.DialogDraft, error) {
|
||||
s.mu.RLock()
|
||||
items := s.drafts[userID]
|
||||
out := make([]domain.DialogDraft, 0, len(peers))
|
||||
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
if draft, ok := items[draftKey(peer, 0)]; ok {
|
||||
out = append(out, cloneDialogDraft(draft))
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) ClearDrafts(_ context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
if limit <= 0 || limit > domain.MaxDialogDraftsPerUser {
|
||||
limit = domain.MaxDialogDraftsPerUser
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package memory
|
|||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -29,6 +30,38 @@ func mediaCategoryMatches(media *domain.MessageMedia, entities []domain.MessageE
|
|||
return false
|
||||
}
|
||||
|
||||
func mediaSearchCommonMatches(id, date int, body string, reply *domain.MessageReply, req domain.MediaSearchRequest) bool {
|
||||
if req.Query != "" && !strings.Contains(strings.ToLower(body), strings.ToLower(req.Query)) {
|
||||
return false
|
||||
}
|
||||
if req.MinDate > 0 && date <= req.MinDate {
|
||||
return false
|
||||
}
|
||||
if req.MaxDate > 0 && date >= req.MaxDate {
|
||||
return false
|
||||
}
|
||||
if req.TopMsgID != 0 && id != req.TopMsgID && (reply == nil || reply.TopMessageID != req.TopMsgID) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func savedMessageHasAnyTag(tags []domain.MessageReaction, wanted []domain.MessageReaction) bool {
|
||||
if len(wanted) == 0 {
|
||||
return true
|
||||
}
|
||||
have := make(map[string]struct{}, len(tags))
|
||||
for _, reaction := range tags {
|
||||
have[reaction.Key()] = struct{}{}
|
||||
}
|
||||
for _, reaction := range wanted {
|
||||
if _, ok := have[reaction.Key()]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pageMediaIDs 把全部匹配 id 按 newest-first 分页(返回本页 id + 满足 max/min 的总数)。
|
||||
func pageMediaIDs(ids []int, req domain.MediaSearchRequest) ([]int, int) {
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
|
||||
|
|
@ -80,7 +113,19 @@ func (s *MessageStore) SearchPrivateMedia(ctx context.Context, ownerUserID, peer
|
|||
s.mu.RLock()
|
||||
matched := make([]int, 0, len(s.m[ownerUserID]))
|
||||
for _, msg := range s.m[ownerUserID] {
|
||||
if msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peerID {
|
||||
if msg.Deleted || msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peerID {
|
||||
continue
|
||||
}
|
||||
if req.SenderUserID != 0 && (msg.From.Type != domain.PeerTypeUser || msg.From.ID != req.SenderUserID) {
|
||||
continue
|
||||
}
|
||||
if !mediaSearchCommonMatches(msg.ID, msg.Date, msg.Body, msg.ReplyTo, req) {
|
||||
continue
|
||||
}
|
||||
if req.SavedPeer.ID != 0 && msg.SavedPeer != req.SavedPeer {
|
||||
continue
|
||||
}
|
||||
if !savedMessageHasAnyTag(s.savedMessageTags[ownerUserID][msg.ID], req.SavedReactions) {
|
||||
continue
|
||||
}
|
||||
if mediaCategoryMatches(msg.Media, msg.Entities, set) {
|
||||
|
|
@ -110,7 +155,7 @@ func (s *MessageStore) CountPrivateMediaCategories(_ context.Context, ownerUserI
|
|||
defer s.mu.RUnlock()
|
||||
out := domain.MediaCategoryCounts{}
|
||||
for _, msg := range s.m[ownerUserID] {
|
||||
if msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peerID {
|
||||
if msg.Deleted || msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peerID {
|
||||
continue
|
||||
}
|
||||
for _, category := range domain.ClassifyMediaCategories(msg.Media, msg.Entities) {
|
||||
|
|
@ -129,7 +174,7 @@ func (s *ChannelStore) SearchChannelMedia(ctx context.Context, viewerUserID, cha
|
|||
return domain.ChannelHistory{}, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
_, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
s.mu.RUnlock()
|
||||
return domain.ChannelHistory{}, err
|
||||
|
|
@ -139,6 +184,20 @@ func (s *ChannelStore) SearchChannelMedia(ctx context.Context, viewerUserID, cha
|
|||
if msg.Deleted || msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum {
|
||||
if member.CanManageDirectMessages() && msg.SavedPeer.ID != 0 {
|
||||
continue
|
||||
}
|
||||
if !member.CanManageDirectMessages() && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID}) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if req.SenderUserID != 0 && msg.SenderUserID != req.SenderUserID {
|
||||
continue
|
||||
}
|
||||
if !mediaSearchCommonMatches(msg.ID, msg.Date, msg.Body, msg.ReplyTo, req) {
|
||||
continue
|
||||
}
|
||||
if mediaCategoryMatches(msg.Media, msg.Entities, set) {
|
||||
matched = append(matched, msg.ID)
|
||||
}
|
||||
|
|
@ -165,7 +224,7 @@ func (s *ChannelStore) CountChannelMediaCategories(_ context.Context, viewerUser
|
|||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.MediaCategoryCounts{}, err
|
||||
}
|
||||
|
|
@ -174,6 +233,14 @@ func (s *ChannelStore) CountChannelMediaCategories(_ context.Context, viewerUser
|
|||
if msg.Deleted || msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum {
|
||||
if member.CanManageDirectMessages() && msg.SavedPeer.ID != 0 {
|
||||
continue
|
||||
}
|
||||
if !member.CanManageDirectMessages() && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID}) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, category := range domain.ClassifyMediaCategories(msg.Media, msg.Entities) {
|
||||
if category != domain.MediaCategoryNone {
|
||||
out[category]++
|
||||
|
|
|
|||
92
internal/store/memory/media_search_test.go
Normal file
92
internal/store/memory/media_search_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func testPhotoMedia(id int64) *domain.MessageMedia {
|
||||
return &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindPhoto,
|
||||
Photo: &domain.Photo{ID: id, AccessHash: id + 100},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateMediaSearchCombinesQuerySenderAndDate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMessageStore()
|
||||
const alice, bob = int64(1001), int64(1002)
|
||||
send := func(sender, recipient, randomID int64, body string, date int) {
|
||||
t.Helper()
|
||||
if _, err := store.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: sender, RecipientUserID: recipient, RandomID: randomID,
|
||||
Message: body, Media: testPhotoMedia(randomID), Date: date,
|
||||
}); err != nil {
|
||||
t.Fatalf("send private media: %v", err)
|
||||
}
|
||||
}
|
||||
send(alice, bob, 1, "needle outside date", 100)
|
||||
send(alice, bob, 2, "needle wanted", 200)
|
||||
send(bob, alice, 3, "needle wrong sender", 210)
|
||||
send(alice, bob, 4, "other text", 220)
|
||||
|
||||
got, err := store.SearchPrivateMedia(ctx, bob, alice, domain.MediaSearchRequest{
|
||||
Categories: []domain.MediaCategory{domain.MediaCategoryPhoto},
|
||||
Query: "NEEDLE",
|
||||
SenderUserID: alice,
|
||||
MinDate: 150,
|
||||
MaxDate: 205,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("search private media: %v", err)
|
||||
}
|
||||
if got.Count != 1 || len(got.Messages) != 1 || got.Messages[0].Body != "needle wanted" {
|
||||
t.Fatalf("combined private media = count %d messages %+v", got.Count, got.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelMediaSearchCombinesQuerySenderAndDate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
Title: "combined media",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{2},
|
||||
Date: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
send := func(sender, randomID int64, body string, date int) {
|
||||
t.Helper()
|
||||
if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: sender, ChannelID: created.Channel.ID, RandomID: randomID,
|
||||
Message: body, Media: testPhotoMedia(randomID), Date: date,
|
||||
}); err != nil {
|
||||
t.Fatalf("send channel media: %v", err)
|
||||
}
|
||||
}
|
||||
send(1, 11, "needle outside date", 110)
|
||||
send(2, 12, "needle wrong sender", 210)
|
||||
send(1, 13, "needle wanted", 220)
|
||||
send(1, 14, "other text", 230)
|
||||
|
||||
got, err := store.SearchChannelMedia(ctx, 1, created.Channel.ID, domain.MediaSearchRequest{
|
||||
Categories: []domain.MediaCategory{domain.MediaCategoryPhoto},
|
||||
Query: "NEEDLE",
|
||||
SenderUserID: 1,
|
||||
MinDate: 200,
|
||||
MaxDate: 225,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("search channel media: %v", err)
|
||||
}
|
||||
if got.Count != 1 || len(got.Messages) != 1 || got.Messages[0].Body != "needle wanted" {
|
||||
t.Fatalf("combined channel media = count %d messages %+v", got.Count, got.Messages)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,14 +2,13 @@ package memory
|
|||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// PhoneChangeStore 是测试用内存实现。用户唯一性在 UserStore 锁内维护;事件写入
|
||||
// 共享 UpdateEventStore 后可由 updates.getDifference 重放。
|
||||
// PhoneChangeStore 是测试用内存实现。用户唯一性在 UserStore 锁内维护;
|
||||
// updateUserPhone 无 PTS,所以不向 UpdateEventStore 写 durable event。
|
||||
type PhoneChangeStore struct {
|
||||
users *UserStore
|
||||
events store.UpdateEventStore
|
||||
|
|
@ -19,9 +18,7 @@ func NewPhoneChangeStore(users *UserStore, events store.UpdateEventStore) *Phone
|
|||
return &PhoneChangeStore{users: users, events: events}
|
||||
}
|
||||
|
||||
func (*PhoneChangeStore) UsesReliableDispatch() bool { return false }
|
||||
|
||||
func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) {
|
||||
func (s *PhoneChangeStore) ChangePhone(_ context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) {
|
||||
if s == nil || s.users == nil || req.UserID == 0 || !domain.ValidPhone(req.Phone) {
|
||||
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberInvalid
|
||||
}
|
||||
|
|
@ -41,37 +38,8 @@ func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChan
|
|||
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
}
|
||||
currentPhone := u.Phone
|
||||
currentSignupEmail := u.SignupEmail
|
||||
u.Phone = req.Phone
|
||||
if req.SignupEmail != "" {
|
||||
u.SignupEmail = req.SignupEmail
|
||||
}
|
||||
s.users.byID[req.UserID] = u
|
||||
|
||||
date := req.Date
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
event := domain.UpdateEvent{
|
||||
UserID: req.UserID,
|
||||
Type: domain.UpdateEventUserPhone,
|
||||
Date: date,
|
||||
Phone: req.Phone,
|
||||
PtsCount: 1,
|
||||
}
|
||||
if s.events != nil {
|
||||
var err error
|
||||
event, err = s.events.AppendAllocated(ctx, req.UserID, event)
|
||||
if err != nil {
|
||||
// 保持内存替身与 PG 的 user+event 原子可见语义。
|
||||
u.Phone = currentPhone
|
||||
u.SignupEmail = currentSignupEmail
|
||||
s.users.byID[req.UserID] = u
|
||||
s.users.mu.Unlock()
|
||||
return domain.PhoneChangeResult{}, err
|
||||
}
|
||||
}
|
||||
s.users.mu.Unlock()
|
||||
return domain.PhoneChangeResult{User: u, Event: event, Changed: true}, nil
|
||||
return domain.PhoneChangeResult{User: u, Changed: true}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,13 +28,13 @@ func cloneSecretChat(c domain.SecretChat) domain.SecretChat {
|
|||
}
|
||||
|
||||
func (s *SecretChatStore) CreateSecretChat(_ context.Context, chat domain.SecretChat) error {
|
||||
if chat.ID == 0 {
|
||||
return domain.ErrSecretChatNotFound
|
||||
if chat.ID == 0 || chat.ID != int(chat.RandomID) {
|
||||
return domain.ErrSecretChatRandomIDDuplicate
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.chats[chat.ID]; exists {
|
||||
return domain.ErrSecretChatIDConflict
|
||||
return domain.ErrSecretChatRandomIDDuplicate
|
||||
}
|
||||
if chat.State == "" {
|
||||
chat.State = domain.SecretChatStateRequested
|
||||
|
|
@ -53,18 +53,6 @@ func (s *SecretChatStore) GetSecretChat(_ context.Context, chatID int) (domain.S
|
|||
return cloneSecretChat(c), true, nil
|
||||
}
|
||||
|
||||
func (s *SecretChatStore) GetByAdminRandom(_ context.Context, adminAuthKeyID int64, randomID int32) (domain.SecretChat, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// 仅返回非终态匹配(与部分唯一索引 WHERE state <> 'discarded' 一致)。
|
||||
for _, c := range s.chats {
|
||||
if c.AdminAuthKeyID == adminAuthKeyID && c.RandomID == randomID && !c.Terminal() {
|
||||
return cloneSecretChat(c), true, nil
|
||||
}
|
||||
}
|
||||
return domain.SecretChat{}, false, nil
|
||||
}
|
||||
|
||||
func (s *SecretChatStore) AcceptSecretChat(_ context.Context, chatID int, participantAuthKeyID int64, gb []byte, keyFingerprint int64) (domain.SecretChat, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -126,18 +114,6 @@ func (s *SecretChatStore) ListActiveSecretChatsByAuthKey(_ context.Context, auth
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SecretChatStore) MaxSecretChatID(_ context.Context) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
max := 0
|
||||
for id := range s.chats {
|
||||
if id > max {
|
||||
max = id
|
||||
}
|
||||
}
|
||||
return max, nil
|
||||
}
|
||||
|
||||
// EncryptedQueueStore 是 store.EncryptedQueueStore 的进程内实现。
|
||||
type EncryptedQueueStore struct {
|
||||
mu sync.Mutex
|
||||
|
|
|
|||
|
|
@ -549,6 +549,52 @@ func (s *StoryStore) GetPeerStoryProjections(_ context.Context, viewerUserID int
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StoryStore) ActiveStoryPeerExpirations(_ context.Context, peers []domain.Peer, now int) (map[domain.Peer]int, error) {
|
||||
if len(peers) > domain.MaxStoryIDs {
|
||||
return nil, domain.ErrStoryIDInvalid
|
||||
}
|
||||
requested := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if err := validateStoryPeer(peer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requested[peer] = struct{}{}
|
||||
}
|
||||
out := make(map[domain.Peer]int, len(peers))
|
||||
s.mu.RLock()
|
||||
for _, story := range s.stories {
|
||||
if _, ok := requested[story.Owner]; !ok || !story.Active(now) {
|
||||
continue
|
||||
}
|
||||
if story.ExpireDate > out[story.Owner] {
|
||||
out[story.Owner] = story.ExpireDate
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StoryStore) ListHiddenStoryPeers(_ context.Context, viewerUserID int64) ([]domain.Peer, error) {
|
||||
if viewerUserID == 0 {
|
||||
return nil, domain.ErrStoryPeerInvalid
|
||||
}
|
||||
out := make([]domain.Peer, 0)
|
||||
s.mu.RLock()
|
||||
for key, hidden := range s.hidden {
|
||||
if key.viewerID == viewerUserID && hidden {
|
||||
out = append(out, domain.Peer{Type: key.peerType, ID: key.peerID})
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Type != out[j].Type {
|
||||
return out[i].Type < out[j].Type
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StoryStore) MarkRead(_ context.Context, viewerUserID int64, peer domain.Peer, maxID, date int) (domain.StoryReadResult, error) {
|
||||
if viewerUserID == 0 {
|
||||
return domain.StoryReadResult{}, domain.ErrStoryPeerInvalid
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。
|
||||
|
|
@ -18,11 +20,11 @@ type UserStore struct {
|
|||
usernameRegistry *CollectibleUsernameStore
|
||||
}
|
||||
|
||||
// NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather / Stickers / ChatBot)
|
||||
// NewUserStore 创建内存 UserStore。内置系统账号
|
||||
// 预置进表,与 postgres 的迁移种子保持双 store 行为一致。
|
||||
func NewUserStore() *UserStore {
|
||||
s := &UserStore{byID: make(map[int64]domain.User), nextID: domain.UserIDSequenceBase}
|
||||
for _, id := range []int64{domain.OfficialSystemUserID, domain.BotFatherUserID, domain.StickersBotUserID, domain.ChatBotUserID, domain.GifBotUserID} {
|
||||
for _, id := range domain.SystemUserIDs() {
|
||||
if u, ok := domain.SystemUserByID(id); ok {
|
||||
s.byID[u.ID] = u
|
||||
}
|
||||
|
|
@ -453,6 +455,24 @@ func (s *UserStore) UpdateLastSeen(_ context.Context, userID int64, lastSeenAt i
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdateLastSeenBatch(ctx context.Context, updates []store.UserLastSeenUpdate) error {
|
||||
latest := make(map[int64]int, len(updates))
|
||||
for _, update := range updates {
|
||||
if update.UserID == 0 || update.LastSeenAt <= 0 {
|
||||
continue
|
||||
}
|
||||
if current := latest[update.UserID]; update.LastSeenAt > current {
|
||||
latest[update.UserID] = update.LastSeenAt
|
||||
}
|
||||
}
|
||||
for userID, lastSeenAt := range latest {
|
||||
if err := s.UpdateLastSeen(ctx, userID, lastSeenAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func userMatchesSearch(u domain.User, query, phoneQuery string) bool {
|
||||
if phoneQuery != "" && strings.HasPrefix(u.Phone, phoneQuery) {
|
||||
return true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue