375 lines
11 KiB
Go
375 lines
11 KiB
Go
package userprojection
|
|
|
|
import (
|
|
"context"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
)
|
|
|
|
// ProfilePhotoProvider returns current profile photos for a batch of owners.
|
|
type ProfilePhotoProvider interface {
|
|
CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error)
|
|
}
|
|
|
|
// ProfilePhotoKindProvider returns current profile/fallback photos for a batch of owners.
|
|
type ProfilePhotoKindProvider interface {
|
|
CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error)
|
|
}
|
|
|
|
// PrivacyEvaluator answers viewer-specific visibility for one user privacy key.
|
|
type PrivacyEvaluator interface {
|
|
CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error)
|
|
}
|
|
|
|
// Projector builds the current viewer's user view for RPC response payloads.
|
|
// It intentionally stays in app/domain types; tg.* conversion remains in rpc.
|
|
type Projector struct {
|
|
contacts store.ContactStore
|
|
photos ProfilePhotoProvider
|
|
privacy PrivacyEvaluator
|
|
}
|
|
|
|
// Option configures a Projector.
|
|
type Option func(*Projector)
|
|
|
|
// WithContactStore enables viewer-specific contact name/phone projection.
|
|
func WithContactStore(c store.ContactStore) Option {
|
|
return func(p *Projector) { p.contacts = c }
|
|
}
|
|
|
|
// WithPhotoProvider enables current profile photo enrichment.
|
|
func WithPhotoProvider(photos ProfilePhotoProvider) Option {
|
|
return func(p *Projector) { p.photos = photos }
|
|
}
|
|
|
|
// WithPrivacyEvaluator enables profile/photo/status privacy projection.
|
|
func WithPrivacyEvaluator(privacy PrivacyEvaluator) Option {
|
|
return func(p *Projector) { p.privacy = privacy }
|
|
}
|
|
|
|
// New creates a user projector.
|
|
func New(opts ...Option) *Projector {
|
|
p := &Projector{}
|
|
for _, opt := range opts {
|
|
opt(p)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// ForViewer applies both current profile photos and owner-specific contact view.
|
|
func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
|
if p == nil {
|
|
return users, nil
|
|
}
|
|
return projectBatch(ctx, p.contacts, p.photos, p.privacy, viewerUserID, users)
|
|
}
|
|
|
|
// One applies ForViewer to a single user.
|
|
func (p *Projector) One(ctx context.Context, viewerUserID int64, user domain.User) (domain.User, error) {
|
|
if p == nil {
|
|
return user, nil
|
|
}
|
|
projected, err := p.ForViewer(ctx, viewerUserID, []domain.User{user})
|
|
if err != nil || len(projected) == 0 {
|
|
return domain.User{}, err
|
|
}
|
|
return projected[0], nil
|
|
}
|
|
|
|
// WithProfilePhotos enriches users with their current avatar from profile photo storage.
|
|
// The lookup is best-effort: a storage error keeps the original user list.
|
|
func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users []domain.User) []domain.User {
|
|
if photos == nil || len(users) == 0 {
|
|
return users
|
|
}
|
|
ids := make([]int64, 0, len(users))
|
|
seen := make(map[int64]struct{}, len(users))
|
|
for _, u := range users {
|
|
if u.ID == 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[u.ID]; ok {
|
|
continue
|
|
}
|
|
seen[u.ID] = struct{}{}
|
|
ids = append(ids, u.ID)
|
|
}
|
|
if len(ids) == 0 {
|
|
return users
|
|
}
|
|
refs, err := photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids)
|
|
if err != nil || len(refs) == 0 {
|
|
return users
|
|
}
|
|
out := make([]domain.User, len(users))
|
|
copy(out, users)
|
|
for i := range out {
|
|
if ref, ok := refs[out[i].ID]; ok {
|
|
applyPhotoRef(&out[i], ref)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ForViewer applies the owner-specific user view that Telegram clients expect.
|
|
// In particular, phone is visible for self and contacts; non-contacts should not
|
|
// receive a phone field because TDesktop will prefer it over the public name.
|
|
func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
|
if contacts == nil || viewerUserID == 0 || len(users) == 0 {
|
|
return users, nil
|
|
}
|
|
out := make([]domain.User, len(users))
|
|
copy(out, users)
|
|
cache := make(map[int64]domain.User, len(users))
|
|
for i := range out {
|
|
u := out[i]
|
|
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID {
|
|
continue
|
|
}
|
|
if projected, ok := cache[u.ID]; ok {
|
|
out[i] = projected
|
|
continue
|
|
}
|
|
projected, err := projectOne(ctx, contacts, viewerUserID, u)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cache[u.ID] = projected
|
|
out[i] = projected
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// One applies ForViewer to a single user.
|
|
func One(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) {
|
|
projected, err := ForViewer(ctx, contacts, viewerUserID, []domain.User{user})
|
|
if err != nil || len(projected) == 0 {
|
|
return domain.User{}, err
|
|
}
|
|
return projected[0], nil
|
|
}
|
|
|
|
func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
|
if len(users) == 0 {
|
|
return users, nil
|
|
}
|
|
out := make([]domain.User, len(users))
|
|
copy(out, users)
|
|
ids := uniqueUserIDs(out)
|
|
profileRefs := map[int64]domain.ProfilePhotoRef{}
|
|
fallbackRefs := map[int64]domain.ProfilePhotoRef{}
|
|
personalRefs := map[int64]domain.ProfilePhotoRef{}
|
|
if photos != nil && len(ids) > 0 {
|
|
if kindPhotos, ok := photos.(ProfilePhotoKindProvider); ok {
|
|
refs, err := kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
profileRefs = refs
|
|
refs, err = kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindFallback)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fallbackRefs = refs
|
|
} else {
|
|
refs, err := photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
profileRefs = refs
|
|
}
|
|
}
|
|
var contactsByID map[int64]domain.Contact
|
|
if contacts != nil && viewerUserID != 0 && len(ids) > 0 {
|
|
var err error
|
|
contactsByID, err = contacts.GetMany(ctx, viewerUserID, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
personalRefs, err = contacts.PersonalPhotos(ctx, viewerUserID, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
cache := make(map[int64]domain.User, len(out))
|
|
for i := range out {
|
|
u := out[i]
|
|
if u.ID == 0 {
|
|
continue
|
|
}
|
|
if projected, ok := cache[u.ID]; ok {
|
|
out[i] = projected
|
|
continue
|
|
}
|
|
projected := applyBasePhotos(u, profileRefs, fallbackRefs, personalRefs, viewerUserID)
|
|
if viewerUserID != 0 && u.ID != viewerUserID && u.ID != domain.OfficialSystemUserID {
|
|
contact, found := contactsByID[u.ID]
|
|
projected = applyContactProjection(projected, contact, found)
|
|
var err error
|
|
projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, found, profileRefs, fallbackRefs, personalRefs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
cache[u.ID] = projected
|
|
out[i] = projected
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) {
|
|
contact, found, err := contacts.Get(ctx, viewerUserID, user.ID)
|
|
if err != nil {
|
|
return domain.User{}, err
|
|
}
|
|
if !found {
|
|
user.Phone = ""
|
|
user.Contact = false
|
|
user.Mutual = false
|
|
return user, nil
|
|
}
|
|
projected := user
|
|
projected.Contact = true
|
|
projected.Mutual = contact.Mutual || contact.User.Mutual
|
|
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
|
|
}
|
|
|
|
func uniqueUserIDs(users []domain.User) []int64 {
|
|
seen := make(map[int64]struct{}, len(users))
|
|
ids := make([]int64, 0, len(users))
|
|
for _, user := range users {
|
|
if user.ID == 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[user.ID]; ok {
|
|
continue
|
|
}
|
|
seen[user.ID] = struct{}{}
|
|
ids = append(ids, user.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef, viewerUserID int64) domain.User {
|
|
if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) {
|
|
return user
|
|
}
|
|
clearPhoto(&user)
|
|
if viewerUserID != 0 && user.ID != viewerUserID {
|
|
if ref, ok := personalRefs[user.ID]; ok && ref.PhotoID != 0 {
|
|
ref.Personal = true
|
|
applyPhotoRef(&user, ref)
|
|
return user
|
|
}
|
|
}
|
|
if ref, ok := profileRefs[user.ID]; ok && ref.PhotoID != 0 {
|
|
applyPhotoRef(&user, ref)
|
|
return user
|
|
}
|
|
if ref, ok := fallbackRefs[user.ID]; ok && ref.PhotoID != 0 {
|
|
applyPhotoRef(&user, ref)
|
|
}
|
|
return user
|
|
}
|
|
|
|
func applyContactProjection(user domain.User, contact domain.Contact, found bool) domain.User {
|
|
if !found {
|
|
user.Phone = ""
|
|
user.Contact = false
|
|
user.Mutual = false
|
|
return user
|
|
}
|
|
user.Contact = true
|
|
user.Mutual = contact.Mutual || contact.User.Mutual
|
|
if contact.User.Phone != "" {
|
|
user.Phone = contact.User.Phone
|
|
} else {
|
|
user.Phone = contact.Phone
|
|
}
|
|
if contact.User.FirstName != "" || contact.User.LastName != "" {
|
|
user.FirstName = contact.User.FirstName
|
|
user.LastName = contact.User.LastName
|
|
} else if contact.FirstName != "" || contact.LastName != "" {
|
|
user.FirstName = contact.FirstName
|
|
user.LastName = contact.LastName
|
|
}
|
|
return user
|
|
}
|
|
|
|
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
|
|
if privacy == nil {
|
|
return user, nil
|
|
}
|
|
phoneAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyPhoneNumber)
|
|
if err != nil {
|
|
return domain.User{}, err
|
|
}
|
|
if !phoneAllowed && !isContact {
|
|
user.Phone = ""
|
|
}
|
|
statusAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyStatusTimestamp)
|
|
if err != nil {
|
|
return domain.User{}, err
|
|
}
|
|
if !statusAllowed {
|
|
user.LastSeenAt = 0
|
|
if user.Status.Kind == domain.UserStatusOnline || user.Status.Kind == domain.UserStatusOffline {
|
|
user.Status = domain.UserStatus{Kind: domain.UserStatusRecently}
|
|
}
|
|
}
|
|
if ref, ok := personalRefs[user.ID]; ok && ref.PhotoID != 0 {
|
|
ref.Personal = true
|
|
applyPhotoRef(&user, ref)
|
|
return user, nil
|
|
}
|
|
if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) && user.PhotoID == 0 {
|
|
return user, nil
|
|
}
|
|
profileAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyProfilePhoto)
|
|
if err != nil {
|
|
return domain.User{}, err
|
|
}
|
|
if profileAllowed {
|
|
if ref, ok := profileRefs[user.ID]; ok && ref.PhotoID != 0 {
|
|
applyPhotoRef(&user, ref)
|
|
return user, nil
|
|
}
|
|
}
|
|
if ref, ok := fallbackRefs[user.ID]; ok && ref.PhotoID != 0 {
|
|
applyPhotoRef(&user, ref)
|
|
return user, nil
|
|
}
|
|
clearPhoto(&user)
|
|
return user, nil
|
|
}
|
|
|
|
func hasPhotoLookups(profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) bool {
|
|
return len(profileRefs) != 0 || len(fallbackRefs) != 0 || len(personalRefs) != 0
|
|
}
|
|
|
|
func applyPhotoRef(user *domain.User, ref domain.ProfilePhotoRef) {
|
|
user.PhotoID = ref.PhotoID
|
|
user.PhotoDCID = ref.DCID
|
|
user.PhotoStripped = append([]byte(nil), ref.Stripped...)
|
|
user.PhotoPersonal = ref.Personal
|
|
}
|
|
|
|
func clearPhoto(user *domain.User) {
|
|
user.PhotoID = 0
|
|
user.PhotoDCID = 0
|
|
user.PhotoStripped = nil
|
|
user.PhotoPersonal = false
|
|
}
|