package rpc import ( "context" "errors" "time" "github.com/iamxvbaba/td/proto" "github.com/iamxvbaba/td/tg" "go.uber.org/zap" "github.com/iamxvbaba/td/tlprofile" "telesrv/internal/domain" ) // registerPhotos 注册 photos.* RPC handler(头像上传 / 切换 / 查询 / 删除)。 func (r *Router) registerPhotos(d *tlprofile.Dispatcher) { registerRPC[*tg.PhotosUploadProfilePhotoRequest](d, tlprofile.SemanticMethodPhotosUploadProfilePhoto, func(ctx context.Context, layerRequest *tg.PhotosUploadProfilePhotoRequest) (any, error) { return r.onPhotosUploadProfilePhoto(ctx, layerRequest) }) registerRPC[*tg.PhotosUpdateProfilePhotoRequest](d, tlprofile.SemanticMethodPhotosUpdateProfilePhoto, func(ctx context.Context, layerRequest *tg.PhotosUpdateProfilePhotoRequest) (any, error) { return r.onPhotosUpdateProfilePhoto(ctx, layerRequest) }) registerRPC[*tg.PhotosUploadContactProfilePhotoRequest](d, tlprofile.SemanticMethodPhotosUploadContactProfilePhoto, func(ctx context.Context, layerRequest *tg.PhotosUploadContactProfilePhotoRequest) (any, error) { return r.onPhotosUploadContactProfilePhoto(ctx, layerRequest) }) registerRPC[*tg.PhotosGetUserPhotosRequest](d, tlprofile.SemanticMethodPhotosGetUserPhotos, func(ctx context.Context, layerRequest *tg.PhotosGetUserPhotosRequest) (any, error) { return r.onPhotosGetUserPhotos(ctx, layerRequest) }) registerRPC[*tg.PhotosDeletePhotosRequest](d, tlprofile.SemanticMethodPhotosDeletePhotos, func(ctx context.Context, layerRequest *tg.PhotosDeletePhotosRequest) (any, error) { return r.onPhotosDeletePhotos(ctx, layerRequest. ID) }) } func (r *Router) onPhotosUploadProfilePhoto(ctx context.Context, req *tg.PhotosUploadProfilePhotoRequest) (*tg.PhotosPhoto, error) { if r.deps.Files == nil { return nil, notImplementedErr() } userID, ok, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } if !ok || userID == 0 { return nil, photoInvalidErr() } targetUserID := userID var botTarget domain.User bot, hasBot := req.GetBot() if target, isBotTarget, err := r.resolveProfilePhotoBotTarget(ctx, userID, bot, hasBot); err != nil { return nil, err } else if isBotTarget { botTarget = target targetUserID = target.ID } upload, mediaFlags, err := parseProfilePhotoUpload(req) if err != nil { return nil, err } if mediaFlags == 0 { return nil, photoInvalidErr() } kind := domain.ProfilePhotoKindProfile if req.GetFallback() { kind = domain.ProfilePhotoKindFallback } photo, err := r.createProfilePhoto(ctx, userID, upload) if err != nil { return nil, err } photo, found, err := r.deps.Files.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, targetUserID, kind, photo.ID, int(r.clock.Now().Unix())) if err != nil { return nil, internalErr() } if !found { return nil, photoInvalidErr() } if botTarget.ID != 0 { return r.photosPhotoForBotTarget(ctx, userID, botTarget.ID, photo, kind), nil } return r.photosPhotoForSelf(ctx, userID, photo, kind), nil } func (r *Router) onPhotosUpdateProfilePhoto(ctx context.Context, req *tg.PhotosUpdateProfilePhotoRequest) (*tg.PhotosPhoto, error) { if r.deps.Files == nil { return nil, notImplementedErr() } userID, ok, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } if !ok || userID == 0 { return nil, photoInvalidErr() } targetUserID := userID var botTarget domain.User bot, hasBot := req.GetBot() if target, isBotTarget, err := r.resolveProfilePhotoBotTarget(ctx, userID, bot, hasBot); err != nil { return nil, err } else if isBotTarget { botTarget = target targetUserID = target.ID } kind := domain.ProfilePhotoKindProfile if req.GetFallback() { kind = domain.ProfilePhotoKindFallback } switch in := req.ID.(type) { case *tg.InputPhoto: photo, found, err := r.deps.Files.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, targetUserID, kind, in.ID, int(r.clock.Now().Unix())) if err != nil { return nil, internalErr() } if !found { return nil, photoInvalidErr() } if botTarget.ID != 0 { return r.photosPhotoForBotTarget(ctx, userID, botTarget.ID, photo, kind), nil } return r.photosPhotoForSelf(ctx, userID, photo, kind), nil default: // InputPhotoEmpty:移除当前头像(停用现有当前照片)。 if cur, found, err := r.deps.Files.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, targetUserID, kind); err == nil && found { _, _ = r.deps.Files.DeleteProfilePhotosKind(ctx, domain.PeerTypeUser, targetUserID, kind, []int64{cur.ID}) } if botTarget.ID != 0 { return r.photosPhotoForBotTarget(ctx, userID, botTarget.ID, domain.Photo{}, kind), nil } return r.photosPhotoForSelf(ctx, userID, domain.Photo{}, kind), nil } } func (r *Router) resolveProfilePhotoBotTarget(ctx context.Context, ownerUserID int64, bot tg.InputUserClass, hasBot bool) (domain.User, bool, error) { if !hasBot { return domain.User{}, false, nil } if bot == nil || ownerUserID == 0 || r.deps.Users == nil || r.deps.Bots == nil { return domain.User{}, false, botInvalidErr() } target, found, err := r.userFromInput(ctx, ownerUserID, bot) if err != nil { return domain.User{}, false, internalErr() } if !found || target.ID == 0 { return domain.User{}, false, botInvalidErr() } owns, err := r.deps.Bots.OwnsBot(ctx, ownerUserID, target.ID) if err != nil { return domain.User{}, false, internalErr() } if !owns { return domain.User{}, false, botInvalidErr() } return target, true, nil } func (r *Router) onPhotosUploadContactProfilePhoto(ctx context.Context, req *tg.PhotosUploadContactProfilePhotoRequest) (*tg.PhotosPhoto, error) { if r.deps.Files == nil || r.deps.Users == nil { return nil, notImplementedErr() } userID, ok, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } if !ok || userID == 0 { return nil, photoInvalidErr() } target, found, err := r.userFromInput(ctx, userID, req.UserID) if err != nil { return nil, internalErr() } if !found || target.ID == 0 || target.ID == userID { return nil, userIDInvalidErr() } upload, mediaFlags, err := parseProfilePhotoUpload(req) if err != nil { return nil, err } if mediaFlags == 0 && req.GetSave() { if r.deps.Contacts == nil { return nil, notImplementedErr() } if _, err := r.deps.Contacts.ClearPersonalPhoto(ctx, userID, target.ID, int(r.clock.Now().Unix())); err != nil { return nil, contactErr(err) } r.invalidateRPCProjectionForPeer(userID, domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}) return r.photosPhotoForUser(ctx, userID, target.ID, domain.Photo{}), nil } if mediaFlags == 0 { return nil, photoInvalidErr() } photo, err := r.createProfilePhoto(ctx, userID, upload) if err != nil { return nil, err } if req.GetSuggest() { if r.deps.Messages == nil { return nil, notImplementedErr() } res, err := r.sendSuggestedProfilePhotoMessage(ctx, userID, target.ID, photo) if err != nil { return nil, err } r.pushSuggestedProfilePhotoCurrentSession(ctx, userID, res) return r.photosPhotoForUser(ctx, userID, target.ID, photo), nil } if r.deps.Contacts == nil { return nil, notImplementedErr() } if _, err := r.deps.Contacts.SetPersonalPhoto(ctx, userID, target.ID, photo, int(r.clock.Now().Unix())); err != nil { return nil, contactErr(err) } r.invalidateRPCProjectionForPeer(userID, domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}) return r.photosPhotoForUserWithPersonalPhoto(ctx, userID, target.ID, photo), nil } type profilePhotoUploadRequest interface { GetFile() (tg.InputFileClass, bool) GetVideo() (tg.InputFileClass, bool) GetVideoStartTs() (float64, bool) GetVideoEmojiMarkup() (tg.VideoSizeClass, bool) } type profilePhotoUpload struct { file tg.InputFileClass hasFile bool video tg.InputFileClass hasVideo bool videoStartTs float64 markup tg.VideoSizeClass hasMarkup bool } func parseProfilePhotoUpload(req profilePhotoUploadRequest) (profilePhotoUpload, int, error) { file, hasFile := req.GetFile() video, hasVideo := req.GetVideo() videoStartTs, hasVideoStartTs := req.GetVideoStartTs() markup, hasMarkup := req.GetVideoEmojiMarkup() if hasFile && file == nil { return profilePhotoUpload{}, 0, photoInvalidErr() } if hasVideo && video == nil { return profilePhotoUpload{}, 0, photoInvalidErr() } if hasMarkup && markup == nil { return profilePhotoUpload{}, 0, photoInvalidErr() } if hasFile && (hasVideo || hasMarkup) { return profilePhotoUpload{}, 0, photoInvalidErr() } if hasVideoStartTs && !hasVideo { return profilePhotoUpload{}, 0, photoInvalidErr() } mediaFlags := 0 if hasFile || hasVideo || hasMarkup { mediaFlags = 1 } return profilePhotoUpload{ file: file, hasFile: hasFile, video: video, hasVideo: hasVideo, videoStartTs: videoStartTs, markup: markup, hasMarkup: hasMarkup, }, mediaFlags, nil } func (r *Router) createProfilePhoto(ctx context.Context, userID int64, upload profilePhotoUpload) (domain.Photo, error) { switch { case upload.hasFile: ref, ok := uploadedFileRef(userID, upload.file) if !ok { return domain.Photo{}, fileReferenceInvalidErr() } photo, err := r.deps.Files.CreateAvatarFromUpload(ctx, ref) if err != nil { return domain.Photo{}, photoUploadErr(err) } return photo, nil case upload.hasVideo && upload.hasMarkup: ref, ok := uploadedFileRef(userID, upload.video) if !ok { return domain.Photo{}, fileReferenceInvalidErr() } size, ok := domainPhotoVideoMarkup(upload.markup) if !ok { return domain.Photo{}, photoInvalidErr() } photo, err := r.deps.Files.CreateAvatarVideoMarkupFromUpload(ctx, ref, upload.videoStartTs, size) if err != nil { return domain.Photo{}, photoUploadErr(err) } return photo, nil case upload.hasVideo: ref, ok := uploadedFileRef(userID, upload.video) if !ok { return domain.Photo{}, fileReferenceInvalidErr() } photo, err := r.deps.Files.CreateAvatarVideoFromUpload(ctx, ref, upload.videoStartTs) if err != nil { return domain.Photo{}, photoUploadErr(err) } return photo, nil case upload.hasMarkup: size, ok := domainPhotoVideoMarkup(upload.markup) if !ok { return domain.Photo{}, photoInvalidErr() } photo, err := r.deps.Files.CreateAvatarMarkup(ctx, size) if err != nil { return domain.Photo{}, photoUploadErr(err) } return photo, nil default: return domain.Photo{}, photoInvalidErr() } } func domainPhotoVideoMarkup(markup tg.VideoSizeClass) (domain.PhotoSize, bool) { switch v := markup.(type) { case *tg.VideoSizeEmojiMarkup: if v.EmojiID == 0 || len(v.BackgroundColors) == 0 { return domain.PhotoSize{}, false } return domain.PhotoSize{ Kind: domain.PhotoSizeKindVideoEmojiMarkup, EmojiID: v.EmojiID, BackgroundColors: append([]int(nil), v.BackgroundColors...), }, true case *tg.VideoSizeStickerMarkup: if v.StickerID == 0 || len(v.BackgroundColors) == 0 { return domain.PhotoSize{}, false } size := domain.PhotoSize{ Kind: domain.PhotoSizeKindVideoStickerMarkup, StickerID: v.StickerID, BackgroundColors: append([]int(nil), v.BackgroundColors...), } setRef, ok := stickerSetRefFromInput(v.Stickerset) if !ok { return domain.PhotoSize{}, false } switch setRef.Kind { case domain.StickerSetRefByID: if setRef.ID == 0 { return domain.PhotoSize{}, false } size.StickerSetID = setRef.ID size.StickerSetAccessHash = setRef.AccessHash case domain.StickerSetRefByShortName: if setRef.ShortName == "" { return domain.PhotoSize{}, false } size.StickerSetShortName = setRef.ShortName case domain.StickerSetRefBySystem: if setRef.SystemKey == "" { return domain.PhotoSize{}, false } size.StickerSetSystemKey = setRef.SystemKey default: return domain.PhotoSize{}, false } return size, true default: return domain.PhotoSize{}, false } } func (r *Router) sendSuggestedProfilePhotoMessage(ctx context.Context, userID, targetUserID int64, photo domain.Photo) (domain.SendPrivateTextResult, error) { if photo.ID == 0 { return domain.SendPrivateTextResult{}, photoInvalidErr() } recipientBlocked, err := r.peerBlocksUser(ctx, userID, targetUserID) if err != nil { return domain.SendPrivateTextResult{}, err } sessionID, _ := SessionIDFrom(ctx) photoCopy := photo res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ SenderUserID: userID, RecipientUserID: targetUserID, RandomID: contactProfilePhotoSuggestRandomID(userID, targetUserID, photo.ID, r.clock.Now().UnixNano()), Media: &domain.MessageMedia{ Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ Kind: domain.MessageServiceActionSuggestProfilePhoto, Photo: &photoCopy, }, }, Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionID, RecipientBlocked: recipientBlocked, }) if err != nil { return domain.SendPrivateTextResult{}, messageSendErr(err) } return res, nil } func contactProfilePhotoSuggestRandomID(userID, targetUserID, photoID, nowNano int64) int64 { id := nowNano ^ (userID << 17) ^ (targetUserID << 7) ^ photoID if id == 0 { return photoID } return id } func (r *Router) pushSuggestedProfilePhotoCurrentSession(ctx context.Context, userID int64, res domain.SendPrivateTextResult) { if res.SenderMessage.ID == 0 || res.SenderEvent.Pts == 0 { return } updates := tgPrivateMessageUpdates( res.SenderEvent, res.SenderMessage, 0, false, r.usersForMessageUpdate(ctx, userID, res.SenderMessage), r.chatsForMessageUpdate(ctx, userID, res.SenderMessage), ) r.pushCurrentSessionMessage(ctx, "push suggested profile photo service message", updates) } func (r *Router) onPhotosGetUserPhotos(ctx context.Context, req *tg.PhotosGetUserPhotosRequest) (tg.PhotosPhotosClass, error) { if r.deps.Files == nil { return &tg.PhotosPhotos{}, nil } currentUserID, ok, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } if !ok || currentUserID == 0 { return nil, userIDInvalidErr() } target, found, err := r.userFromInput(ctx, currentUserID, req.UserID) if err != nil { return nil, internalErr() } if !found { return nil, userIDInvalidErr() } offset := req.Offset if offset < -1 { offset = -1 } if offset < 0 && req.MaxID <= 0 { offset = 0 } limit := req.Limit if limit <= 0 || limit > 100 { limit = 100 } photos, total, err := r.deps.Files.GetProfilePhotos(ctx, domain.PeerTypeUser, target.ID, offset, limit, req.MaxID) if err != nil { return nil, internalErr() } tgPhotos := make([]tg.PhotoClass, 0, len(photos)) for _, p := range photos { tgPhotos = append(tgPhotos, tgPhoto(p)) } // 打开自己的头像相册(inputUserSelf 或显式自己 ID)时 target 即 viewer,须带 self // 标志,否则 self=false 的自己 user 会污染 DrKLO 账号缓存(Saved Messages 变身)。 selfUser := r.tgUser(target) if target.ID == currentUserID { selfUser = r.tgSelfUser(target) } users := []tg.UserClass{selfUser} r.applyPeerReadModels(ctx, currentUserID, users, nil) countOffset := offset if countOffset < 0 { countOffset = 0 } if total > len(photos)+countOffset { return &tg.PhotosPhotosSlice{Count: total, Photos: tgPhotos, Users: users}, nil } return &tg.PhotosPhotos{Photos: tgPhotos, Users: users}, nil } func (r *Router) onPhotosDeletePhotos(ctx context.Context, id []tg.InputPhotoClass) ([]int64, error) { if r.deps.Files == nil { return []int64{}, nil } userID, ok, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } if !ok || userID == 0 { return nil, photoInvalidErr() } ids := make([]int64, 0, len(id)) for _, in := range id { if photo, isPhoto := in.(*tg.InputPhoto); isPhoto && photo.ID != 0 { ids = append(ids, photo.ID) } } if len(ids) == 0 { return []int64{}, nil } deleted, err := r.deps.Files.DeleteProfilePhotos(ctx, domain.PeerTypeUser, userID, ids) if err != nil { return nil, internalErr() } r.invalidateRPCProjectionForUser(userID) // 删除可能撤掉当前头像(回落到下一张或无头像):与 uploadProfilePhoto 同一纪律, // 向全部在线 session(含当前)推 updateUser + fresh self(对齐参考实现 deletePhotos 的 // SyncPushUpdates),否则其它设备与当前设备(DrKLO 本地删除逻辑同样不重建 has_video) // 头像停留在旧状态。 if deleted > 0 && r.deps.Users != nil { if self, err := r.deps.Users.Self(ctx, userID); err == nil { r.pushSelfPhotoUpdate(ctx, self) } } return ids, nil } // photosPhotoForSelf 组装 photos.photo 响应(新照片 + 带头像的 self user),并在头像变更后 // 向该账号其它在线设备推送,使其即时刷新头像。仅由 uploadProfilePhoto / updateProfilePhoto // 等头像变更路径调用(只读路径不得使用,否则会误触发推送)。 func (r *Router) photosPhotoForSelf(ctx context.Context, userID int64, photo domain.Photo, kind domain.ProfilePhotoKind) *tg.PhotosPhoto { out := &tg.PhotosPhoto{Photo: tgPhoto(photo), Users: []tg.UserClass{}} r.invalidateRPCProjectionForUser(userID) if r.deps.Users == nil { return out } self, err := r.deps.Users.Self(ctx, userID) if err != nil { return out } if kind == domain.ProfilePhotoKindProfile { applyProfilePhotoToUser(&self, photo) } projected := r.tgSelfUser(self) pushed := r.tgSelfUser(self) r.applyUsernamesToPeerObjects(ctx, []tg.UserClass{projected, pushed}, nil) out.Users = append(out.Users, projected) r.pushSelfPhotoUpdateWithUser(ctx, self, pushed) return out } func (r *Router) photosPhotoForBotTarget(ctx context.Context, viewerUserID, botUserID int64, photo domain.Photo, kind domain.ProfilePhotoKind) *tg.PhotosPhoto { out := &tg.PhotosPhoto{Photo: tgPhoto(photo), Users: []tg.UserClass{}} r.invalidateRPCProjectionForUser(botUserID) if r.deps.Users == nil { return out } bot, found, err := r.deps.Users.ByID(ctx, viewerUserID, botUserID) if err != nil || !found { return out } if kind == domain.ProfilePhotoKindProfile { applyProfilePhotoToUser(&bot, photo) } projected := r.tgUser(bot) _ = r.applyBotCanEditToUser(ctx, viewerUserID, bot, projected) pushed := r.tgUser(bot) _ = r.applyBotCanEditToUser(ctx, viewerUserID, bot, pushed) r.applyUsernamesToPeerObjects(ctx, []tg.UserClass{projected, pushed}, nil) out.Users = append(out.Users, projected) r.pushBotPhotoUpdateToOwner(ctx, viewerUserID, bot, pushed) return out } func (r *Router) photosPhotoForUser(ctx context.Context, viewerUserID, targetUserID int64, photo domain.Photo) *tg.PhotosPhoto { out := &tg.PhotosPhoto{Photo: tgPhoto(photo), Users: []tg.UserClass{}} if r.deps.Users == nil { return out } user, found, err := r.deps.Users.ByID(ctx, viewerUserID, targetUserID) if err != nil || !found { return out } out.Users = append(out.Users, r.tgUser(user)) r.applyUsernamesToPeerObjects(ctx, out.Users, nil) return out } func (r *Router) photosPhotoForUserWithPersonalPhoto(ctx context.Context, viewerUserID, targetUserID int64, photo domain.Photo) *tg.PhotosPhoto { out := r.photosPhotoForUser(ctx, viewerUserID, targetUserID, photo) if photo.ID == 0 || len(out.Users) == 0 { return out } user, ok := out.Users[0].(*tg.User) if !ok || user == nil { return out } user.Photo = &tg.UserProfilePhoto{PhotoID: photo.ID, DCID: photo.DCID, Personal: true} if domain.PhotoHasVideo(photo.Sizes) { user.Photo.(*tg.UserProfilePhoto).SetHasVideo(true) } if stripped := domain.StrippedFromSizes(photo.Sizes); len(stripped) > 0 { user.Photo.(*tg.UserProfilePhoto).SetStrippedThumb(stripped) } return out } func applyProfilePhotoToUser(user *domain.User, photo domain.Photo) { if user == nil { return } if photo.ID == 0 { user.PhotoID = 0 user.PhotoDCID = 0 user.PhotoStripped = nil user.PhotoPersonal = false user.PhotoHasVideo = false return } user.PhotoID = photo.ID user.PhotoDCID = photo.DCID user.PhotoStripped = domain.StrippedFromSizes(photo.Sizes) user.PhotoPersonal = false user.PhotoHasVideo = domain.PhotoHasVideo(photo.Sizes) } // pushSelfPhotoUpdate 向该账号全部在线设备(含当前 session)推送头像变更,对齐参考实现 // SyncPushUpdates 的全 session 语义。updateUserName 不含 photo 无法刷新头像;updateUser 只是 // 「该 user 变了」的信号(TDesktop 仅当 peer 已 full-loaded 时才 forceFull 重拉);最可靠是在 // Updates.Users 带上含新 userProfilePhoto 的完整 self user,TDesktop 经 // processUser→setPhoto→peerUpdated(Photo) 即时刷新。 // // 当前 session 不能只依赖 RPC 返回:DrKLO Android 的 uploadProfilePhoto 响应回调 // (ProfileActivity/SettingsActivity.didUploadPhoto、PhotoUtilities)不消费 // photos.photo.users,而是手工重建 userProfilePhoto——只填 photo_id/photo_small/photo_big, // 丢掉 has_video/stripped_thumb;emoji/sticker markup 头像的本地渲染 // (ImageReceiver.setForUserOrChat → VectorAvatarThumbDrawable)要求 // user.photo.has_video==true,缺推送时该设备要等到下一次拿到 fresh self user(通常是重启) // 才会显示 emoji 头像。当前 session 的回显延迟发送,保证到达时客户端已处理完自己的 // RPC 响应回调(否则 Android 回调会把推送刚修好的 photo 再次覆盖回无 has_video 的形状)。 func (r *Router) pushSelfPhotoUpdate(ctx context.Context, self domain.User) { if self.ID == 0 { return } r.pushSelfPhotoUpdateWithUser(ctx, self, r.tgSelfUserWithUsernames(ctx, self)) } func (r *Router) pushSelfPhotoUpdateWithUser(ctx context.Context, self domain.User, projected *tg.User) { if self.ID == 0 || projected == nil { return } 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) { if ownerUserID == 0 || bot.ID == 0 || projected == nil { return } 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 的延迟: // 必须晚于 RPC 结果写出与客户端响应回调,否则 DrKLO 的手工 photo 重建会覆盖回显内容。 // updateUser 无 pts,晚到/丢失不影响 difference 正确性。 const defaultSelfPhotoEchoPushDelay = 500 * time.Millisecond // pushSelfPhotoUpdateToCurrentSession 延迟向当前 session 回显头像变更 updates。 // ctx 值在调度前捕获(请求 ctx 在 handler 返回后即失效)。 func (r *Router) pushSelfPhotoUpdateToCurrentSession(ctx context.Context, updates *tg.Updates) { if r.deps.Sessions == nil || updates == nil { return } sessionID, ok := SessionIDFrom(ctx) if !ok { return } rawAuthKeyID := rawAuthKeyIDForOrigin(ctx) push := func() { pushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := r.deps.Sessions.PushToSessionForAuthKey(pushCtx, rawAuthKeyID, sessionID, proto.MessageFromServer, updates); err != nil { r.log.Debug("push self photo update to current session", zap.Int64("session_id", sessionID), zap.Error(err)) } } if r.selfPhotoEchoPushDelay <= 0 { push() return } time.AfterFunc(r.selfPhotoEchoPushDelay, push) } func selfPhotoUpdates(self domain.User, date int, user tg.UserClass) *tg.Updates { return &tg.Updates{ Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: self.ID}}, Users: []tg.UserClass{user}, Date: date, } } // uploadedFileRef 把 tg.InputFile / InputFileBig 转成 domain.UploadedFileRef。 func uploadedFileRef(ownerUserID int64, file tg.InputFileClass) (domain.UploadedFileRef, bool) { switch f := file.(type) { case *tg.InputFile: if f.ID == 0 || f.Parts <= 0 { return domain.UploadedFileRef{}, false } return domain.UploadedFileRef{OwnerUserID: ownerUserID, FileID: f.ID, Parts: f.Parts, Name: f.Name, MD5: f.MD5Checksum}, true case *tg.InputFileBig: if f.ID == 0 || f.Parts <= 0 { return domain.UploadedFileRef{}, false } return domain.UploadedFileRef{OwnerUserID: ownerUserID, FileID: f.ID, Parts: f.Parts, Name: f.Name, Big: true}, true default: return domain.UploadedFileRef{}, false } } // resolveInputChatPhoto 把 tg.InputChatPhoto 解析为 *domain.Photo(nil=清除头像)。 // 支持新上传(InputChatUploadedPhoto)与引用已有照片(InputChatPhoto{InputPhoto})。 func (r *Router) resolveInputChatPhoto(ctx context.Context, userID int64, input tg.InputChatPhotoClass) (*domain.Photo, error) { switch in := input.(type) { case *tg.InputChatPhotoEmpty: return nil, nil case *tg.InputChatUploadedPhoto: file, ok := in.GetFile() if !ok { return nil, photoInvalidErr() } if r.deps.Files == nil { return nil, photoInvalidErr() } ref, ok := uploadedFileRef(userID, file) if !ok { return nil, fileReferenceInvalidErr() } // 频道/群头像用 avatar 尺寸('a'/'c'),匹配 InputPeerPhotoFileLocation 下载路径。 photo, err := r.deps.Files.CreateAvatarFromUpload(ctx, ref) if err != nil { return nil, photoUploadErr(err) } return &photo, nil case *tg.InputChatPhoto: switch id := in.ID.(type) { case *tg.InputPhoto: if r.deps.Files == nil { return nil, photoInvalidErr() } photo, found, err := r.deps.Files.GetPhoto(ctx, id.ID) if err != nil { return nil, internalErr() } if !found { return nil, photoInvalidErr() } return &photo, nil default: return nil, nil // InputPhotoEmpty → 清除 } default: return nil, photoInvalidErr() } } func photoUploadErr(err error) error { switch { case errors.Is(err, domain.ErrFilePartsInvalid): return filePartsInvalidErr() case errors.Is(err, domain.ErrPhotoInvalid): return photoInvalidErr() case errors.Is(err, domain.ErrStorageFull): return storageFullErr() default: return internalErr() } }