From a00f6ad814e72c6fe7466af2de0f0c29b542b10b Mon Sep 17 00:00:00 2001 From: onysd Date: Thu, 3 Sep 2026 09:50:15 +0300 Subject: [PATCH] fix for avatar updates --- internal/rpc/photos.go | 49 +++++++++++++++++++++++++ internal/rpc/photos_push_test.go | 61 ++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/internal/rpc/photos.go b/internal/rpc/photos.go index 3c2cf035..40d2cc2d 100644 --- a/internal/rpc/photos.go +++ b/internal/rpc/photos.go @@ -642,6 +642,7 @@ func (r *Router) pushSelfPhotoUpdateWithUser(ctx context.Context, self domain.Us updates := selfPhotoUpdates(self, int(r.clock.Now().Unix()), projected) r.pushUserUpdates(ctx, self.ID, updates) r.pushSelfPhotoUpdateToCurrentSession(ctx, updates) + r.pushPeerPhotoUpdate(ctx, self.ID) } func (r *Router) pushBotPhotoUpdateToOwner(ctx context.Context, ownerUserID int64, bot domain.User, projected *tg.User) { @@ -651,6 +652,54 @@ func (r *Router) pushBotPhotoUpdateToOwner(ctx context.Context, ownerUserID int6 updates := selfPhotoUpdates(bot, int(r.clock.Now().Unix()), projected) r.pushUserUpdates(ctx, ownerUserID, updates) r.pushSelfPhotoUpdateToCurrentSession(ctx, updates) + // The bot's own dialog peers (anyone who has chatted with it) never + // received anything here before -- only the human managing the bot's + // profile did (above). A bot has no contacts, so presenceFanoutCandidates + // resolves to its private-dialog peers only, which is exactly who needs + // to see a changed bot avatar. + r.pushPeerPhotoUpdate(ctx, bot.ID) +} + +// pushPeerPhotoUpdate notifies ownerUserID's contacts and private-dialog +// peers that its profile photo changed, reusing presenceFanoutCandidates +// (the same "contacts ∪ private-dialog peers" set presence pushes already +// compute) -- until this existed, a changed avatar was never pushed to +// anyone except the owning account's own other sessions, so a contact's chat +// window kept showing the old avatar until they happened to reopen the chat +// or restart their client. +// +// Sends only a bare tg.UpdateUser{UserID: ownerUserID} signal, never an +// embedded tg.User: unlike the owner's own devices (which can always see +// their own photo, so pushSelfPhotoUpdateWithUser safely embeds it), a +// peer's view of ownerUserID's photo is privacy-gated +// (domain.PrivacyKeyProfilePhoto) -- baking the raw photo in here would +// bypass that gate. The bare signal instead makes the recipient's client +// re-fetch ownerUserID through the ordinary users/getFullUser path, which +// already applies that privacy check correctly. +// +// Uses the durable per-recipient push path (pushUserMessage), not the +// transient one presence uses: a missed avatar refresh is far stickier and +// more noticeable than a missed status blip, so a recipient who is offline +// right now should still pick this up on their next reconnect via normal +// update dispatch, not just while they happen to be online already. +func (r *Router) pushPeerPhotoUpdate(ctx context.Context, ownerUserID int64) { + if ownerUserID == 0 { + return + } + recipients := r.presenceFanoutCandidates(ctx, ownerUserID) + if len(recipients) == 0 { + return + } + update := &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: ownerUserID}}, + Date: int(r.clock.Now().Unix()), + } + for _, recipientID := range recipients { + if recipientID == 0 || recipientID == ownerUserID { + continue + } + r.pushUserMessage(ctx, recipientID, "push peer photo update", update) + } } // defaultSelfPhotoEchoPushDelay 是头像变更后向当前 session 回显 updateUser 的延迟: diff --git a/internal/rpc/photos_push_test.go b/internal/rpc/photos_push_test.go index ae5ee9c3..a11b7759 100644 --- a/internal/rpc/photos_push_test.go +++ b/internal/rpc/photos_push_test.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap/zaptest" botsapp "telesrv/internal/app/bots" + dialogsapp "telesrv/internal/app/dialogs" appusers "telesrv/internal/app/users" "telesrv/internal/domain" "telesrv/internal/store/memory" @@ -483,3 +484,63 @@ func TestUploadProfilePhotoFallbackEmojiAndInvalidFlags(t *testing.T) { t.Fatalf("current profile photo after invalid = %+v ok=%v err=%v, want preserved 778", cur, ok, err) } } + +// TestUploadProfilePhotoNotifiesPrivateDialogPeer guards the fix for "avatars +// never refresh live": before pushPeerPhotoUpdate existed, a changed avatar +// was pushed only to the owner's own other sessions (see +// TestUploadProfilePhotoPushesUpdateToOtherDevices above) -- a private-dialog +// peer got nothing at all and kept showing the stale avatar until they +// happened to reopen the chat or restart their client. peer must now receive +// a bare UpdateUser{UserID: owner} signal (never an embedded tg.User -- see +// pushPeerPhotoUpdate's doc comment on why the raw photo must not be baked in +// for a privacy-gated peer view). +func TestUploadProfilePhotoNotifiesPrivateDialogPeer(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + dialogStore := memory.NewDialogStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001009", FirstName: "Owner"}) + peer, _ := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550001010", FirstName: "Peer"}) + // A private dialog from owner's side is exactly what marks peer as a + // presence/photo fan-out candidate -- see presenceFanoutCandidates. + if err := dialogStore.Upsert(ctx, owner.ID, domain.Dialog{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peer.ID}}); err != nil { + t.Fatalf("seed dialog: %v", err) + } + sessions := &captureSessions{} + files := &fakeFiles{} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore, appusers.WithPhotoProvider(files)), + Files: files, + Sessions: sessions, + Dialogs: dialogsapp.NewService(dialogStore), + }, zaptest.NewLogger(t), clock.System) + + req := &tg.PhotosUploadProfilePhotoRequest{} + req.SetFile(&tg.InputFile{ID: 42, Parts: 1, Name: "a.jpg"}) + if _, err := r.onPhotosUploadProfilePhoto(WithUserID(ctx, owner.ID), req); err != nil { + t.Fatalf("uploadProfilePhoto: %v", err) + } + + // pushPeerPhotoUpdate runs last inside pushSelfPhotoUpdateWithUser (after + // the owner's own-session push), so it's both the last pushed user id and + // the last recorded userMessage -- see that function's call order. + pushed := sessions.pushedUserIDs() + if len(pushed) == 0 || pushed[len(pushed)-1] != peer.ID { + t.Fatalf("pushed user ids = %v, want peer %d last", pushed, peer.ID) + } + peerUpdates, ok := sessions.lastUserPush().(*tg.Updates) + if !ok { + t.Fatalf("peer push = %T, want *tg.Updates", sessions.lastUserPush()) + } + hasSignal := false + for _, u := range peerUpdates.Updates { + if uu, ok := u.(*tg.UpdateUser); ok && uu.UserID == owner.ID { + hasSignal = true + } + } + if !hasSignal { + t.Fatalf("peer updates = %+v, want bare UpdateUser signal for owner", peerUpdates.Updates) + } + if len(peerUpdates.Users) != 0 { + t.Fatalf("peer updates.Users = %+v, want empty -- raw photo must not bypass privacy for a peer push", peerUpdates.Users) + } +}