From 40743dfb09b3b459bf6b04db55cd63b5df9b46d5 Mon Sep 17 00:00:00 2001 From: A Date: Tue, 21 Jul 2026 15:48:33 +0800 Subject: [PATCH] fix: sync contact note projection --- internal/app/userprojection/contact_cache.go | 3 + .../app/userprojection/contact_cache_test.go | 17 +- internal/app/userprojection/projection.go | 32 +-- .../app/userprojection/projection_test.go | 9 +- internal/domain/user.go | 5 + internal/rpc/contacts.go | 142 ++++++++++--- internal/rpc/contacts_users_rpc_test.go | 197 ++++++++++++++++++ internal/rpc/users.go | 50 +++++ 8 files changed, 395 insertions(+), 60 deletions(-) diff --git a/internal/app/userprojection/contact_cache.go b/internal/app/userprojection/contact_cache.go index a89caf7c..64946a2e 100644 --- a/internal/app/userprojection/contact_cache.go +++ b/internal/app/userprojection/contact_cache.go @@ -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 } diff --git a/internal/app/userprojection/contact_cache_test.go b/internal/app/userprojection/contact_cache_test.go index f272f3a6..ed88e3f4 100644 --- a/internal/app/userprojection/contact_cache_test.go +++ b/internal/app/userprojection/contact_cache_test.go @@ -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) diff --git a/internal/app/userprojection/projection.go b/internal/app/userprojection/projection.go index 60fe7d19..ded9a3b5 100644 --- a/internal/app/userprojection/projection.go +++ b/internal/app/userprojection/projection.go @@ -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 { diff --git a/internal/app/userprojection/projection_test.go b/internal/app/userprojection/projection_test.go index f6013c3c..7f17dfc2 100644 --- a/internal/app/userprojection/projection_test.go +++ b/internal/app/userprojection/projection_test.go @@ -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) diff --git a/internal/domain/user.go b/internal/domain/user.go index 372ff3f3..22413065 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -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 diff --git a/internal/rpc/contacts.go b/internal/rpc/contacts.go index e77940b5..026c3dc1 100644 --- a/internal/rpc/contacts.go +++ b/internal/rpc/contacts.go @@ -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 { diff --git a/internal/rpc/contacts_users_rpc_test.go b/internal/rpc/contacts_users_rpc_test.go index 16095d7b..6c44e580 100644 --- a/internal/rpc/contacts_users_rpc_test.go +++ b/internal/rpc/contacts_users_rpc_test.go @@ -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() diff --git a/internal/rpc/users.go b/internal/rpc/users.go index bfdc0857..a6e0f70e 100644 --- a/internal/rpc/users.go +++ b/internal/rpc/users.go @@ -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 {