fix: sync contact note projection
This commit is contained in:
parent
d69a34a4a8
commit
40743dfb09
8 changed files with 395 additions and 60 deletions
|
|
@ -451,6 +451,9 @@ func cloneCachedUser(in domain.User) domain.User {
|
|||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
if in.ContactNoteEntities != nil {
|
||||
in.ContactNoteEntities = append([]domain.MessageEntity(nil), in.ContactNoteEntities...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,7 +116,13 @@ func (s *countingContactStore) SetPersonalPhoto(ctx context.Context, userID, con
|
|||
func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice", Phone: "111"}); err != nil {
|
||||
if _, err := base.Upsert(ctx, 1, domain.ContactInput{
|
||||
ContactUserID: 2,
|
||||
FirstName: "Alice",
|
||||
Phone: "111",
|
||||
Note: "private note",
|
||||
NoteEntities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 7}},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
counting := &countingContactStore{ContactStore: base}
|
||||
|
|
@ -126,15 +132,16 @@ func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("get many first: %v", err)
|
||||
}
|
||||
if first[2].FirstName != "Alice" {
|
||||
t.Fatalf("first contact = %+v, want Alice", first[2])
|
||||
if first[2].FirstName != "Alice" || first[2].Note != "private note" || len(first[2].NoteEntities) != 1 {
|
||||
t.Fatalf("first contact = %+v, want Alice with private note", first[2])
|
||||
}
|
||||
first[2].NoteEntities[0].Length = 99
|
||||
second, err := cached.GetMany(ctx, 1, []int64{2, 3})
|
||||
if err != nil {
|
||||
t.Fatalf("get many second: %v", err)
|
||||
}
|
||||
if second[2].FirstName != "Alice" {
|
||||
t.Fatalf("second contact = %+v, want Alice", second[2])
|
||||
if second[2].FirstName != "Alice" || second[2].Note != "private note" || len(second[2].NoteEntities) != 1 || second[2].NoteEntities[0].Length != 7 {
|
||||
t.Fatalf("second contact = %+v, want isolated cached Alice note", second[2])
|
||||
}
|
||||
if counting.listCalls != 1 {
|
||||
t.Fatalf("ListByUser calls = %d, want 1 account snapshot load", counting.listCalls)
|
||||
|
|
|
|||
|
|
@ -260,6 +260,9 @@ func cloneUsers(users []domain.User) []domain.User {
|
|||
}
|
||||
out := make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
for i := range out {
|
||||
out[i].ContactNoteEntities = append([]domain.MessageEntity(nil), out[i].ContactNoteEntities...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
@ -499,30 +502,7 @@ func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID i
|
|||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found {
|
||||
user.Phone = ""
|
||||
user.Contact = false
|
||||
user.Mutual = false
|
||||
user.CloseFriend = false
|
||||
return user, nil
|
||||
}
|
||||
projected := user
|
||||
projected.Contact = true
|
||||
projected.Mutual = contact.Mutual || contact.User.Mutual
|
||||
projected.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
|
||||
if contact.User.Phone != "" {
|
||||
projected.Phone = contact.User.Phone
|
||||
} else {
|
||||
projected.Phone = contact.Phone
|
||||
}
|
||||
if contact.User.FirstName != "" || contact.User.LastName != "" {
|
||||
projected.FirstName = contact.User.FirstName
|
||||
projected.LastName = contact.User.LastName
|
||||
} else if contact.FirstName != "" || contact.LastName != "" {
|
||||
projected.FirstName = contact.FirstName
|
||||
projected.LastName = contact.LastName
|
||||
}
|
||||
return projected, nil
|
||||
return applyContactProjection(user, contact, found), nil
|
||||
}
|
||||
|
||||
func uniqueUserIDs(users []domain.User) []int64 {
|
||||
|
|
@ -575,11 +555,15 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
|||
user.Contact = false
|
||||
user.Mutual = false
|
||||
user.CloseFriend = false
|
||||
user.ContactNote = ""
|
||||
user.ContactNoteEntities = nil
|
||||
return user
|
||||
}
|
||||
user.Contact = true
|
||||
user.Mutual = contact.Mutual || contact.User.Mutual
|
||||
user.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
|
||||
user.ContactNote = contact.Note
|
||||
user.ContactNoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...)
|
||||
if contact.User.Phone != "" {
|
||||
user.Phone = contact.User.Phone
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ func TestProjectorCombinesProfilePhotosAndViewerContacts(t *testing.T) {
|
|||
Phone: "1111",
|
||||
FirstName: "Alice",
|
||||
LastName: "Contact",
|
||||
Note: "private note",
|
||||
NoteEntities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 7}},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
|
|
@ -47,12 +49,15 @@ func TestProjectorCombinesProfilePhotosAndViewerContacts(t *testing.T) {
|
|||
if friend.FirstName != "Alice" || friend.LastName != "Contact" || friend.Phone != "1111" || !friend.Contact {
|
||||
t.Fatalf("friend projection = %+v, want contact name/phone", friend)
|
||||
}
|
||||
if friend.ContactNote != "private note" || len(friend.ContactNoteEntities) != 1 || friend.ContactNoteEntities[0].Type != domain.MessageEntityBold {
|
||||
t.Fatalf("friend contact note = %q %+v, want owner-scoped note", friend.ContactNote, friend.ContactNoteEntities)
|
||||
}
|
||||
if friend.PhotoID != 9001 || friend.PhotoDCID != 2 || string(friend.PhotoStripped) != string([]byte{1, 2}) {
|
||||
t.Fatalf("friend photo = id %d dc %d stripped %v, want 9001/2/[1 2]", friend.PhotoID, friend.PhotoDCID, friend.PhotoStripped)
|
||||
}
|
||||
stranger := projectionUser(t, users, strangerID)
|
||||
if stranger.Phone != "" || stranger.Contact {
|
||||
t.Fatalf("stranger projection = %+v, want hidden phone and non-contact", stranger)
|
||||
if stranger.Phone != "" || stranger.Contact || stranger.ContactNote != "" || len(stranger.ContactNoteEntities) != 0 {
|
||||
t.Fatalf("stranger projection = %+v, want hidden phone and no contact note", stranger)
|
||||
}
|
||||
if stranger.PhotoID != 9002 || stranger.PhotoDCID != 3 {
|
||||
t.Fatalf("stranger photo = id %d dc %d, want 9002/3", stranger.PhotoID, stranger.PhotoDCID)
|
||||
|
|
|
|||
|
|
@ -104,6 +104,11 @@ type User struct {
|
|||
Contact bool
|
||||
Mutual bool
|
||||
CloseFriend bool
|
||||
// ContactNote/ContactNoteEntities are transient viewer-scoped contact
|
||||
// projection fields. They must never be persisted into users or a
|
||||
// viewer-independent base-user cache.
|
||||
ContactNote string
|
||||
ContactNoteEntities []MessageEntity
|
||||
// Bot 标识 bot 账号;置位时 BotInfoVersion 必须 ≥1(TDesktop 只认
|
||||
// user TL 是否携带 bot_info_version 字段,且与 bot flag 共用 bit14)。
|
||||
Bot bool
|
||||
|
|
|
|||
|
|
@ -594,7 +594,11 @@ func (r *Router) onContactsImportContacts(ctx context.Context, input []tg.InputP
|
|||
}
|
||||
items := make([]domain.ContactInput, 0, len(input))
|
||||
for _, item := range input {
|
||||
note, entities := contactNote(item.GetNote())
|
||||
rawNote, hasNote := item.GetNote()
|
||||
note, entities, err := contactNote(userID, rawNote, hasNote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validContactInput(item.Phone, item.FirstName, item.LastName, note, len(entities)) {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
|
|
@ -666,7 +670,11 @@ func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddCo
|
|||
if !found {
|
||||
return nil, contactIDInvalidErr()
|
||||
}
|
||||
note, entities := contactNote(req.GetNote())
|
||||
rawNote, hasNote := req.GetNote()
|
||||
note, entities, err := contactNote(userID, rawNote, hasNote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validContactInput(req.Phone, req.FirstName, req.LastName, note, len(entities)) {
|
||||
return nil, limitInvalidErr()
|
||||
}
|
||||
|
|
@ -682,22 +690,20 @@ func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddCo
|
|||
if err != nil {
|
||||
return nil, contactErr(err)
|
||||
}
|
||||
peerUser := contact.User
|
||||
peerUser.Contact = true
|
||||
peerUser.Mutual = contact.Mutual || contact.User.Mutual
|
||||
if contact.Phone != "" {
|
||||
peerUser.Phone = contact.Phone
|
||||
}
|
||||
if contact.FirstName != "" || contact.LastName != "" {
|
||||
peerUser.FirstName = contact.FirstName
|
||||
peerUser.LastName = contact.LastName
|
||||
}
|
||||
peerUser := contactUserForUpdates(contact)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}
|
||||
settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
updates := r.contactPeerSettingsUpdates(ctx, userID, peerUser, settings, true)
|
||||
if hasNote {
|
||||
// TDesktop does not copy the submitted note into Data::User after
|
||||
// contacts.addContact. updateUser is the lightweight full-info refresh
|
||||
// signal; the private note itself remains available only from
|
||||
// users.getFullUser for this viewer.
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateUser{UserID: peerUser.ID})
|
||||
}
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateContactsReset{})
|
||||
if err := r.recordPeerSettings(ctx, userID, peer, settings); err != nil {
|
||||
return nil, internalErr()
|
||||
|
|
@ -712,6 +718,9 @@ func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddCo
|
|||
}
|
||||
r.invalidateRPCProjectionForViewer(userID)
|
||||
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, updates)
|
||||
if hasNote {
|
||||
r.pushContactNoteRefreshIfReliableDispatch(ctx, userID, peerUser)
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
|
|
@ -739,16 +748,7 @@ func (r *Router) onContactsAcceptContact(ctx context.Context, id tg.InputUserCla
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
peerUser := contact.User
|
||||
peerUser.Contact = true
|
||||
peerUser.Mutual = contact.Mutual || contact.User.Mutual
|
||||
if contact.Phone != "" {
|
||||
peerUser.Phone = contact.Phone
|
||||
}
|
||||
if contact.FirstName != "" || contact.LastName != "" {
|
||||
peerUser.FirstName = contact.FirstName
|
||||
peerUser.LastName = contact.LastName
|
||||
}
|
||||
peerUser := contactUserForUpdates(contact)
|
||||
updates := r.contactPeerSettingsUpdates(ctx, userID, peerUser, settings, true)
|
||||
updates.Updates = append(updates.Updates, &tg.UpdateContactsReset{})
|
||||
if err := r.recordPeerSettings(ctx, userID, peer, settings); err != nil {
|
||||
|
|
@ -844,17 +844,27 @@ func (r *Router) onContactsUpdateContactNote(ctx context.Context, req *tg.Contac
|
|||
if !found {
|
||||
return false, contactIDInvalidErr()
|
||||
}
|
||||
if utf8.RuneCountInString(req.Note.Text) > maxContactNoteLength || len(req.Note.Entities) > maxMessageEntityCount {
|
||||
return false, limitInvalidErr()
|
||||
note, entities, err := contactNote(userID, req.Note, true)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := r.deps.Contacts.UpdateContactNote(ctx, userID, target.ID, req.Note.Text, domainMessageEntities(req.Note.Entities)); err != nil {
|
||||
contact, err := r.deps.Contacts.UpdateContactNote(ctx, userID, target.ID, note, entities)
|
||||
if err != nil {
|
||||
return false, contactErr(err)
|
||||
}
|
||||
if err := r.recordContactsReset(ctx, userID); err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
r.invalidateRPCProjectionForViewer(userID)
|
||||
r.pushContactsReset(ctx, userID)
|
||||
peerUser := contactUserForUpdates(contact)
|
||||
if r.hasReliableUpdateDispatch() {
|
||||
// contactsReset is already delivered by the durable outbox. updateUser
|
||||
// is intentionally a transient online refresh hint and must not copy a
|
||||
// private note into the shared update log.
|
||||
r.pushContactNoteRefreshIfReliableDispatch(ctx, userID, peerUser)
|
||||
} else {
|
||||
r.pushUserUpdates(ctx, userID, r.contactNoteRefreshUpdates(peerUser, int(r.clock.Now().Unix()), true))
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
|
@ -989,11 +999,85 @@ func validContactInput(phone, firstName, lastName, note string, entities int) bo
|
|||
return true
|
||||
}
|
||||
|
||||
func contactNote(note tg.TextWithEntities, ok bool) (string, []domain.MessageEntity) {
|
||||
func contactNote(ownerUserID int64, note tg.TextWithEntities, ok bool) (string, []domain.MessageEntity, error) {
|
||||
if !ok {
|
||||
return "", nil
|
||||
return "", nil, nil
|
||||
}
|
||||
return note.Text, domainMessageEntities(note.Entities)
|
||||
if !utf8.ValidString(note.Text) || utf8.RuneCountInString(note.Text) > maxContactNoteLength || len(note.Entities) > maxMessageEntityCount {
|
||||
return "", nil, limitInvalidErr()
|
||||
}
|
||||
limit := utf16CodeUnitLen(note.Text)
|
||||
for _, entity := range note.Entities {
|
||||
if messageEntityClassNil(entity) || !storyCaptionEntitySupported(entity) {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
offset, length := entity.GetOffset(), entity.GetLength()
|
||||
if offset < 0 || length <= 0 || offset > limit || length > limit-offset {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
switch typed := entity.(type) {
|
||||
case *tg.MessageEntityCustomEmoji:
|
||||
if typed.DocumentID <= 0 {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
case *tg.MessageEntityMentionName:
|
||||
if typed.UserID <= 0 {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
case *tg.InputMessageEntityMentionName:
|
||||
if inputUserClassNil(typed.UserID) {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
}
|
||||
}
|
||||
entities := domainMessageEntitiesForViewer(ownerUserID, note.Entities)
|
||||
if len(entities) != len(note.Entities) || !validEphemeralEntityBounds(note.Text, entities) {
|
||||
return "", nil, entityBoundsInvalidErr()
|
||||
}
|
||||
return note.Text, entities, nil
|
||||
}
|
||||
|
||||
func contactUserForUpdates(contact domain.Contact) domain.User {
|
||||
peerUser := contact.User
|
||||
peerUser.Contact = true
|
||||
peerUser.Mutual = contact.Mutual || contact.User.Mutual
|
||||
if contact.Phone != "" {
|
||||
peerUser.Phone = contact.Phone
|
||||
}
|
||||
if contact.FirstName != "" || contact.LastName != "" {
|
||||
peerUser.FirstName = contact.FirstName
|
||||
peerUser.LastName = contact.LastName
|
||||
}
|
||||
return peerUser
|
||||
}
|
||||
|
||||
func (r *Router) contactNoteRefreshUpdates(peerUser domain.User, date int, includeContactsReset bool) *tg.Updates {
|
||||
updates := make([]tg.UpdateClass, 0, 2)
|
||||
if includeContactsReset {
|
||||
updates = append(updates, &tg.UpdateContactsReset{})
|
||||
}
|
||||
updates = append(updates, &tg.UpdateUser{UserID: peerUser.ID})
|
||||
return &tg.Updates{
|
||||
Updates: updates,
|
||||
Users: []tg.UserClass{r.tgUser(peerUser)},
|
||||
Date: date,
|
||||
}
|
||||
}
|
||||
|
||||
// pushContactNoteRefreshIfReliableDispatch complements the durable
|
||||
// contactsReset event. Reliable dispatch already owns the reset, while this
|
||||
// best-effort online nudge makes other loaded TDesktop profiles refetch
|
||||
// users.getFullUser immediately. Offline correctness does not depend on it.
|
||||
func (r *Router) pushContactNoteRefreshIfReliableDispatch(ctx context.Context, userID int64, peerUser domain.User) {
|
||||
if !r.hasReliableUpdateDispatch() || peerUser.ID == 0 {
|
||||
return
|
||||
}
|
||||
r.pushUserMessageTransient(
|
||||
ctx,
|
||||
userID,
|
||||
"push contact note full-user refresh",
|
||||
r.contactNoteRefreshUpdates(peerUser, int(r.clock.Now().Unix()), false),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *Router) contactPeerSettingsUpdates(ctx context.Context, userID int64, peerUser domain.User, settings domain.PeerSettings, includeSelf bool) *tg.Updates {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
appprivacy "telesrv/internal/app/privacy"
|
||||
appstories "telesrv/internal/app/stories"
|
||||
appupdates "telesrv/internal/app/updates"
|
||||
"telesrv/internal/app/userprojection"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
|
|
@ -724,6 +725,202 @@ func TestAccountUpdateProfileRPC(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUsersGetFullUserProjectsOwnerScopedContactNoteAcrossCacheUpdates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
rawContacts := memory.NewContactStore()
|
||||
cachedContacts := userprojection.NewCachedContactStore(rawContacts, time.Hour)
|
||||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
altOwner, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Alt"})
|
||||
if err != nil {
|
||||
t.Fatalf("create alternate owner: %v", err)
|
||||
}
|
||||
friend, err := userStore.Create(ctx, domain.User{AccessHash: 3, Phone: "15550000003", FirstName: "Friend"})
|
||||
if err != nil {
|
||||
t.Fatalf("create friend: %v", err)
|
||||
}
|
||||
contactsService := appcontacts.NewService(cachedContacts, userStore)
|
||||
usersService := appusers.NewService(userStore, appusers.WithContactStore(cachedContacts))
|
||||
sessions := &captureSessions{}
|
||||
r := New(Config{}, Deps{Users: usersService, Contacts: contactsService, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
||||
hasUserRefresh := func(updates *tg.Updates, userID int64) bool {
|
||||
t.Helper()
|
||||
if updates == nil {
|
||||
return false
|
||||
}
|
||||
for _, update := range updates.Updates {
|
||||
if changed, ok := update.(*tg.UpdateUser); ok && changed.UserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
add := &tg.ContactsAddContactRequest{
|
||||
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
FirstName: "Friend",
|
||||
}
|
||||
add.SetNote(tg.TextWithEntities{
|
||||
Text: "owner note",
|
||||
Entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 5}},
|
||||
})
|
||||
addedClass, err := r.onContactsAddContact(WithUserID(ctx, owner.ID), add)
|
||||
if err != nil {
|
||||
t.Fatalf("add owner contact through RPC: %v", err)
|
||||
}
|
||||
added, ok := addedClass.(*tg.Updates)
|
||||
if !ok || !hasUserRefresh(added, friend.ID) {
|
||||
t.Fatalf("add contact updates = %T %+v, want updateUser refresh for note", addedClass, addedClass)
|
||||
}
|
||||
pushed, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || !hasUserRefresh(pushed, friend.ID) {
|
||||
t.Fatalf("add contact push = %T %+v, want other-session updateUser refresh", sessions.lastUserPush(), sessions.lastUserPush())
|
||||
}
|
||||
if _, err := contactsService.AddContact(ctx, altOwner.ID, domain.ContactInput{
|
||||
ContactUserID: friend.ID,
|
||||
FirstName: "Friend",
|
||||
Note: "alternate note",
|
||||
}); err != nil {
|
||||
t.Fatalf("add alternate owner contact: %v", err)
|
||||
}
|
||||
getNote := func(viewer domain.User) (tg.TextWithEntities, bool) {
|
||||
t.Helper()
|
||||
full, err := r.onUsersGetFullUser(WithUserID(ctx, viewer.ID), &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get full user for viewer %d: %v", viewer.ID, err)
|
||||
}
|
||||
return full.FullUser.GetNote()
|
||||
}
|
||||
|
||||
note, ok := getNote(owner)
|
||||
if !ok || note.Text != "owner note" || len(note.Entities) != 1 {
|
||||
t.Fatalf("owner note = %+v present=%v, want owner note with entity", note, ok)
|
||||
}
|
||||
bold, ok := note.Entities[0].(*tg.MessageEntityBold)
|
||||
if !ok || bold.Offset != 0 || bold.Length != 5 {
|
||||
t.Fatalf("owner note entity = %T %+v, want bold 0/5", note.Entities[0], note.Entities[0])
|
||||
}
|
||||
// The large UserFull LRU intentionally excludes private notes; every response
|
||||
// overlays one from the already-loaded viewer contact projection.
|
||||
cachedFull, ok := r.userFullProjectionCache.Lookup(owner.ID, friend.ID)
|
||||
if !ok {
|
||||
t.Fatal("user full projection was not cached")
|
||||
}
|
||||
if cachedNote, present := cachedFull.GetNote(); present {
|
||||
t.Fatalf("cached user full leaked private note: %+v", cachedNote)
|
||||
}
|
||||
// Mutating one response must not leak through the cache or contact snapshot.
|
||||
bold.Length = 99
|
||||
note, ok = getNote(owner)
|
||||
if !ok || note.Entities[0].(*tg.MessageEntityBold).Length != 5 {
|
||||
t.Fatalf("owner note after response mutation = %+v present=%v", note, ok)
|
||||
}
|
||||
|
||||
altNote, ok := getNote(altOwner)
|
||||
if !ok || altNote.Text != "alternate note" {
|
||||
t.Fatalf("alternate owner note = %+v present=%v, want isolated value", altNote, ok)
|
||||
}
|
||||
if ok, err := r.onContactsUpdateContactNote(WithUserID(ctx, owner.ID), &tg.ContactsUpdateContactNoteRequest{
|
||||
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Note: tg.TextWithEntities{
|
||||
Text: "bad",
|
||||
Entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 3, Length: 1}},
|
||||
},
|
||||
}); err == nil || ok || !strings.Contains(err.Error(), "ENTITY_BOUNDS_INVALID") {
|
||||
t.Fatalf("invalid contact note ok=%v err=%v, want ENTITY_BOUNDS_INVALID", ok, err)
|
||||
}
|
||||
note, ok = getNote(owner)
|
||||
if !ok || note.Text != "owner note" {
|
||||
t.Fatalf("invalid update mutated owner note: %+v present=%v", note, ok)
|
||||
}
|
||||
|
||||
updated := &tg.ContactsUpdateContactNoteRequest{
|
||||
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Note: tg.TextWithEntities{
|
||||
Text: "fresh note",
|
||||
Entities: []tg.MessageEntityClass{&tg.MessageEntityItalic{Offset: 0, Length: 5}},
|
||||
},
|
||||
}
|
||||
sessions.clearMessages()
|
||||
if ok, err := r.onContactsUpdateContactNote(WithUserID(ctx, owner.ID), updated); err != nil || !ok {
|
||||
t.Fatalf("update contact note ok=%v err=%v", ok, err)
|
||||
}
|
||||
pushed, ok = sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok || !hasUserRefresh(pushed, friend.ID) {
|
||||
t.Fatalf("update contact note push = %T %+v, want updateUser refresh", sessions.lastUserPush(), sessions.lastUserPush())
|
||||
}
|
||||
hasReset := false
|
||||
for _, update := range pushed.Updates {
|
||||
if _, ok := update.(*tg.UpdateContactsReset); ok {
|
||||
hasReset = true
|
||||
}
|
||||
}
|
||||
if !hasReset {
|
||||
t.Fatalf("update contact note push = %+v, want contactsReset for non-reliable dispatch", pushed)
|
||||
}
|
||||
note, ok = getNote(owner)
|
||||
if !ok || note.Text != "fresh note" || len(note.Entities) != 1 {
|
||||
t.Fatalf("fresh owner note = %+v present=%v", note, ok)
|
||||
}
|
||||
if _, ok := note.Entities[0].(*tg.MessageEntityItalic); !ok {
|
||||
t.Fatalf("fresh owner note entity = %T, want italic", note.Entities[0])
|
||||
}
|
||||
altNote, ok = getNote(altOwner)
|
||||
if !ok || altNote.Text != "alternate note" {
|
||||
t.Fatalf("alternate note changed with owner update: %+v present=%v", altNote, ok)
|
||||
}
|
||||
|
||||
if ok, err := r.onContactsUpdateContactNote(WithUserID(ctx, owner.ID), &tg.ContactsUpdateContactNoteRequest{
|
||||
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
||||
Note: tg.TextWithEntities{},
|
||||
}); err != nil || !ok {
|
||||
t.Fatalf("clear contact note ok=%v err=%v", ok, err)
|
||||
}
|
||||
if note, present := getNote(owner); present {
|
||||
t.Fatalf("cleared contact note still present: %+v", note)
|
||||
}
|
||||
|
||||
// Simulate a write committed by another instance: both the shared contact
|
||||
// snapshot and RPC projection receive the existing contact_account NOTIFY.
|
||||
if _, found, err := rawContacts.UpdateNote(ctx, owner.ID, friend.ID, "remote note", nil); err != nil || !found {
|
||||
t.Fatalf("remote update found=%v err=%v", found, err)
|
||||
}
|
||||
cachedContacts.InvalidateViewers(owner.ID)
|
||||
r.InvalidateRPCProjectionReadModelForViewer(owner.ID)
|
||||
note, ok = getNote(owner)
|
||||
if !ok || note.Text != "remote note" {
|
||||
t.Fatalf("note after cross-instance invalidation = %+v present=%v", note, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactNoteReliableDispatchPushesOnlyTransientUserRefresh(t *testing.T) {
|
||||
sessions := &captureSessions{}
|
||||
r := New(Config{}, Deps{
|
||||
Sessions: sessions,
|
||||
Updates: &captureUpdates{reliableDispatch: true},
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
peer := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Friend", Contact: true}
|
||||
|
||||
r.pushContactNoteRefreshIfReliableDispatch(WithUserID(context.Background(), 1000000001), 1000000001, peer)
|
||||
|
||||
pushed, ok := sessions.lastUserPush().(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("contact note refresh = %T, want *tg.Updates", sessions.lastUserPush())
|
||||
}
|
||||
if len(pushed.Updates) != 1 {
|
||||
t.Fatalf("contact note refresh updates = %+v, want one updateUser without duplicate contactsReset", pushed.Updates)
|
||||
}
|
||||
changed, ok := pushed.Updates[0].(*tg.UpdateUser)
|
||||
if !ok || changed.UserID != peer.ID {
|
||||
t.Fatalf("contact note refresh update = %T %+v, want updateUser(%d)", pushed.Updates[0], pushed.Updates[0], peer.ID)
|
||||
}
|
||||
if len(pushed.Users) != 1 || pushed.Users[0].GetID() != peer.ID {
|
||||
t.Fatalf("contact note refresh users = %+v, want peer companion", pushed.Users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersSavedMusicStubsValidateInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package rpc
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
|
|
@ -156,6 +157,9 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
|
|||
r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, []tg.UserClass{user}, nil)
|
||||
loadEpoch := r.userFullProjectionCache.LoadEpoch()
|
||||
if full, ok := r.userFullProjectionCache.Lookup(currentUserID, u.ID); ok {
|
||||
if !applyContactNoteToUserFull(u, &full) {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if err := r.applyTranslationDisabledToUserFull(ctx, currentUserID, u.ID, &full); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -173,6 +177,9 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
|
|||
return nil, err
|
||||
}
|
||||
r.userFullProjectionCache.StoreIfEpoch(currentUserID, u.ID, full, loadEpoch)
|
||||
if !applyContactNoteToUserFull(u, &full) {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if err := r.applyTranslationDisabledToUserFull(ctx, currentUserID, u.ID, &full); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -186,6 +193,49 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
|
|||
}, nil
|
||||
}
|
||||
|
||||
// applyContactNoteToUserFull overlays the viewer-scoped contact note after the
|
||||
// expensive UserFull projection cache. This keeps private notes out of the
|
||||
// large LRU while reusing the contact projection already loaded by Users.ByID,
|
||||
// so users.getFullUser adds neither a PostgreSQL query nor an N+1 read.
|
||||
func applyContactNoteToUserFull(user domain.User, full *tg.UserFull) bool {
|
||||
if full == nil {
|
||||
return false
|
||||
}
|
||||
full.Flags2.Unset(22)
|
||||
full.Note = tg.TextWithEntities{}
|
||||
if !user.Contact {
|
||||
return user.ContactNote == "" && len(user.ContactNoteEntities) == 0
|
||||
}
|
||||
if user.ContactNote == "" {
|
||||
return len(user.ContactNoteEntities) == 0
|
||||
}
|
||||
if !utf8.ValidString(user.ContactNote) || utf8.RuneCountInString(user.ContactNote) > maxContactNoteLength ||
|
||||
len(user.ContactNoteEntities) > maxMessageEntityCount || !validEphemeralEntityBounds(user.ContactNote, user.ContactNoteEntities) {
|
||||
return false
|
||||
}
|
||||
entities := tgMessageEntities(user.ContactNoteEntities)
|
||||
if len(entities) != len(user.ContactNoteEntities) {
|
||||
return false
|
||||
}
|
||||
for _, entity := range user.ContactNoteEntities {
|
||||
switch entity.Type {
|
||||
case domain.MessageEntityCustomEmoji:
|
||||
if entity.DocumentID <= 0 {
|
||||
return false
|
||||
}
|
||||
case domain.MessageEntityMentionName:
|
||||
if entity.UserID <= 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
full.SetNote(tg.TextWithEntities{
|
||||
Text: user.ContactNote,
|
||||
Entities: entities,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int64, u domain.User) (tg.UserFull, error) {
|
||||
about := u.About
|
||||
if r.deps.Privacy != nil && u.ID != currentUserID {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue