package rpc import ( "context" "errors" "github.com/gotd/td/tg" "telesrv/internal/domain" ) // registerPhotos 注册 photos.* RPC handler(头像上传 / 切换 / 查询 / 删除)。 func (r *Router) registerPhotos(d *tg.ServerDispatcher) { d.OnPhotosUploadProfilePhoto(r.onPhotosUploadProfilePhoto) d.OnPhotosUpdateProfilePhoto(r.onPhotosUpdateProfilePhoto) d.OnPhotosUploadContactProfilePhoto(r.onPhotosUploadContactProfilePhoto) d.OnPhotosGetUserPhotos(r.onPhotosGetUserPhotos) d.OnPhotosDeletePhotos(r.onPhotosDeletePhotos) } 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() } if bot, hasBot := req.GetBot(); hasBot && bot != nil { return nil, inputConstructorInvalidErr() } file, hasFile := req.GetFile() if !hasFile { // video / emoji markup 等头像变体本阶段不支持。 return nil, photoInvalidErr() } ref, ok := uploadedFileRef(userID, file) if !ok { return nil, fileReferenceInvalidErr() } kind := domain.ProfilePhotoKindProfile if req.GetFallback() { kind = domain.ProfilePhotoKindFallback } photo, err := r.deps.Files.UploadProfilePhotoKind(ctx, domain.PeerTypeUser, userID, kind, ref, int(r.clock.Now().Unix())) if err != nil { return nil, photoUploadErr(err) } return r.photosPhotoForSelf(ctx, userID, photo), 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() } if bot, hasBot := req.GetBot(); hasBot && bot != nil { return nil, inputConstructorInvalidErr() } 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, userID, kind, in.ID, int(r.clock.Now().Unix())) if err != nil { return nil, internalErr() } if !found { return nil, photoInvalidErr() } return r.photosPhotoForSelf(ctx, userID, photo), nil default: // InputPhotoEmpty:移除当前头像(停用现有当前照片)。 if cur, found, err := r.deps.Files.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, kind); err == nil && found { _, _ = r.deps.Files.DeleteProfilePhotosKind(ctx, domain.PeerTypeUser, userID, kind, []int64{cur.ID}) } return r.photosPhotoForSelf(ctx, userID, domain.Photo{}), nil } } func (r *Router) onPhotosUploadContactProfilePhoto(ctx context.Context, req *tg.PhotosUploadContactProfilePhotoRequest) (*tg.PhotosPhoto, error) { if r.deps.Files == nil || r.deps.Contacts == 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() } file, hasFile := req.GetFile() if !hasFile { if req.GetSave() { if _, err := r.deps.Contacts.ClearPersonalPhoto(ctx, userID, target.ID, int(r.clock.Now().Unix())); err != nil { return nil, contactErr(err) } return r.photosPhotoForUser(ctx, userID, target.ID, domain.Photo{}), nil } return nil, photoInvalidErr() } ref, ok := uploadedFileRef(userID, file) if !ok { return nil, fileReferenceInvalidErr() } photo, err := r.deps.Files.CreateAvatarFromUpload(ctx, ref) if err != nil { return nil, photoUploadErr(err) } if req.GetSuggest() { // TODO: add private MessageActionSuggestProfilePhoto once private service-message // actions exist in the domain message model. return r.photosPhotoForUser(ctx, userID, target.ID, photo), nil } if _, err := r.deps.Contacts.SetPersonalPhoto(ctx, userID, target.ID, photo, int(r.clock.Now().Unix())); err != nil { return nil, contactErr(err) } return r.photosPhotoForUser(ctx, userID, target.ID, photo), nil } 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 < 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)) } users := []tg.UserClass{r.tgUser(target)} if total > len(photos)+offset { 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 } if _, err := r.deps.Files.DeleteProfilePhotos(ctx, domain.PeerTypeUser, userID, ids); err != nil { return nil, internalErr() } return ids, nil } // photosPhotoForSelf 组装 photos.photo 响应(新照片 + 带头像的 self user),并在头像变更后 // 向该账号其它在线设备推送,使其即时刷新头像。仅由 uploadProfilePhoto / updateProfilePhoto // 等头像变更路径调用(只读路径不得使用,否则会误触发推送)。 func (r *Router) photosPhotoForSelf(ctx context.Context, userID int64, photo domain.Photo) *tg.PhotosPhoto { out := &tg.PhotosPhoto{Photo: tgPhoto(photo), Users: []tg.UserClass{}} if r.deps.Users == nil { return out } self, err := r.deps.Users.Self(ctx, userID) if err != nil { return out } out.Users = append(out.Users, r.tgSelfUser(self)) r.pushSelfPhotoUpdate(ctx, self) 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)) return out } // pushSelfPhotoUpdate 向该账号其它在线设备推送头像变更。updateUserName 不含 photo 无法刷新 // 头像;updateUser 只是「该 user 变了」的信号(TDesktop 仅当 peer 已 full-loaded 时才 // forceFull 重拉);最可靠是在 Updates.Users 带上含新 userProfilePhoto 的完整 self user, // TDesktop 经 processUser→setPhoto→peerUpdated(Photo) 即时刷新。当前设备同时经 RPC 返回更新, // 重复推送对 TDesktop 幂等(setUserpicChecked 比对 photo_id,相同则 no-op)。 func (r *Router) pushSelfPhotoUpdate(ctx context.Context, self domain.User) { if self.ID == 0 { return } r.pushUserUpdates(ctx, self.ID, &tg.Updates{ Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: self.ID}}, Users: []tg.UserClass{r.tgSelfUser(self)}, Date: int(r.clock.Now().Unix()), }) } // 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() default: return internalErr() } }