package contacts import ( "context" "errors" "strings" "unicode/utf8" "telesrv/internal/app/userprojection" "telesrv/internal/domain" "telesrv/internal/store" ) var ( ErrContactIDInvalid = errors.New("contact id invalid") ErrContactNameEmpty = errors.New("contact name empty") ErrContactReqMissing = errors.New("contact request missing") ) const maxSearchLimit = 50 const maxCloseFriendsCount = 5000 type phonePrivacyService interface { userprojection.PrivacyEvaluator userprojection.BatchPrivacyEvaluator AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) } // Service 提供通讯录查询。 type Service struct { contacts store.ContactStore users store.UserStore photos userprojection.ProfilePhotoProvider privacy phonePrivacyService freezes userprojection.AccountFreezeProvider projector *userprojection.Projector versions store.ReadModelVersionStore cache *contactListReadModelCache // hideThirdPartyVerification mirrors config.HideThirdPartyVerification: // while true, Search never returns @marksbot (domain.VerifierBotUserID), // so the account is unreachable for a client that doesn't already know it. hideThirdPartyVerification bool } // Option adjusts optional contacts service dependencies. type Option func(*Service) // WithPhotoProvider enables current profile photo enrichment for returned users. func WithPhotoProvider(p userprojection.ProfilePhotoProvider) Option { return func(s *Service) { s.photos = p } } // WithPrivacyEvaluator enables viewer-specific privacy projection. func WithPrivacyEvaluator(p phonePrivacyService) Option { return func(s *Service) { s.privacy = p } } func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option { return func(s *Service) { s.freezes = p } } // WithHideThirdPartyVerification mirrors config.HideThirdPartyVerification: // while true, Search filters @marksbot out of every result set. func WithHideThirdPartyVerification(hidden bool) Option { return func(s *Service) { s.hideThirdPartyVerification = hidden } } // WithReadModelVersions enables durable hash-token fast paths for NotModified RPCs. func WithReadModelVersions(v store.ReadModelVersionStore) Option { return func(s *Service) { s.versions = v } } // NewService 创建 contacts 服务。 func NewService(contacts store.ContactStore, users ...store.UserStore) *Service { s := &Service{contacts: contacts, cache: newContactListReadModelCache(defaultContactListReadModelTTL)} if len(users) > 0 { s.users = users[0] } s.rebuildProjector() return s } // Configure applies optional dependencies after construction. func (s *Service) Configure(opts ...Option) *Service { if s == nil { return s } for _, opt := range opts { opt(s) } s.rebuildProjector() return s } func (s *Service) rebuildProjector() { if s == nil { return } s.projector = userprojection.New( userprojection.WithContactStore(s.contacts), userprojection.WithPhotoProvider(s.photos), userprojection.WithPrivacyEvaluator(s.privacy), userprojection.WithAccountFreezeProvider(s.freezes), ) } // GetContacts 返回当前登录账号的通讯录。未登录或无持久化实现时按空账号处理。 func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) { if s == nil || s.contacts == nil || userID == 0 { return domain.ContactList{}, false, nil } currentHash, hasHash, err := s.contactAccountHash(ctx, userID) if err != nil { return domain.ContactList{}, false, err } if hash != 0 && hasHash && hash == currentHash { return domain.ContactList{Hash: currentHash}, true, nil } list, err := s.contactListReadModel(ctx, userID, currentHash, hasHash) if err != nil { return domain.ContactList{}, false, err } if hash != 0 && hash == list.Hash { return list, true, nil } return list, false, nil } func (s *Service) AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) { if s == nil || s.contacts == nil || userID == 0 || input.ContactUserID == 0 || input.ContactUserID == userID { return domain.Contact{}, ErrContactIDInvalid } if input.FirstName == "" && input.LastName == "" { return domain.Contact{}, ErrContactNameEmpty } // Android 的 contacts.addContact 会提交带 "+" 前缀的号码(TDesktop 传纯数字或空)。 // 可解析的完整号码写成与账号相同的 E.164 identity;无法解析的本地名片号码只 // 保留展示 digits,绝不能拿它做账号选择。空串表示客户端只按 user id 添加联系人,必须原样保留; // TL 明确允许省略号码,服务端不得从 target 全局资料反向补出隐私号码。 if input.Phone != "" { if canonical := domain.NormalizePhone(input.Phone); canonical != "" { input.Phone = canonical } else { input.Phone = digitsOnly(input.Phone) } } if s.users != nil { _, found, err := s.users.ByID(ctx, input.ContactUserID) if err != nil { return domain.Contact{}, err } if !found { return domain.Contact{}, ErrContactIDInvalid } } contact, err := s.contacts.Upsert(ctx, userID, input) if err != nil { return domain.Contact{}, err } s.InvalidateViewers(userID, input.ContactUserID) if input.AddPhonePrivacyException && s.privacy != nil { if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, input.ContactUserID); err != nil { return domain.Contact{}, err } } return s.projectContact(ctx, userID, contact) } // AcceptContact creates the reciprocal contact for an existing one-way contact. // Phone visibility remains governed exclusively by account privacy rules; this // RPC has no protocol flag authorizing a hidden phone-number exception. func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64) (domain.Contact, error) { if s == nil || s.contacts == nil || s.users == nil || userID == 0 || contactUserID == 0 || contactUserID == userID { return domain.Contact{}, ErrContactIDInvalid } ownerContact, found, err := s.contacts.Get(ctx, userID, contactUserID) if err != nil { return domain.Contact{}, err } if !found { return domain.Contact{}, ErrContactReqMissing } self, found, err := s.users.ByID(ctx, userID) if err != nil { return domain.Contact{}, err } if !found { return domain.Contact{}, ErrContactIDInvalid } target, found, err := s.users.ByID(ctx, contactUserID) if err != nil { return domain.Contact{}, err } if !found { return domain.Contact{}, ErrContactIDInvalid } if ownerContact.Mutual { return ownerContact, nil } _, err = s.contacts.Upsert(ctx, contactUserID, domain.ContactInput{ ContactUserID: userID, Phone: self.Phone, FirstName: self.FirstName, LastName: self.LastName, }) if err != nil { return domain.Contact{}, err } s.InvalidateViewers(userID, contactUserID) contact, found, err := s.contacts.Get(ctx, userID, target.ID) if err != nil { return domain.Contact{}, err } if !found { return domain.Contact{}, ErrContactReqMissing } return s.projectContact(ctx, userID, contact) } func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error) { if s == nil || s.contacts == nil || s.users == nil || userID == 0 || len(inputs) == 0 { return domain.ImportContactsResult{}, nil } out := domain.ImportContactsResult{ Imported: make([]domain.ImportedContact, 0, len(inputs)), Contacts: make([]domain.Contact, 0, len(inputs)), } normalized := make([]domain.ContactInput, 0, len(inputs)) phones := make([]string, 0, len(inputs)) seenPhones := make(map[string]struct{}, len(inputs)) for _, input := range inputs { phone := domain.NormalizePhone(input.Phone) if phone == "" { continue } input.Phone = phone normalized = append(normalized, input) if _, ok := seenPhones[phone]; ok { continue } seenPhones[phone] = struct{}{} phones = append(phones, phone) } if len(phones) == 0 { return out, nil } targets, err := s.users.ByPhones(ctx, phones) if err != nil { return domain.ImportContactsResult{}, err } if s.privacy != nil && len(targets) > 0 { targetIDs := make([]int64, 0, len(targets)) for _, target := range targets { if target.ID != 0 && target.ID != userID { targetIDs = append(targetIDs, target.ID) } } visibility, err := s.privacy.CanSeeBatch( ctx, targetIDs, userID, []domain.PrivacyKey{domain.PrivacyKeyAddedByPhone}, ) if err != nil { return domain.ImportContactsResult{}, err } allowed := targets[:0] for _, target := range targets { if visibility[target.ID][domain.PrivacyKeyAddedByPhone] { allowed = append(allowed, target) } } targets = allowed } byPhone := make(map[string]domain.User, len(targets)) for _, target := range targets { if target.Phone != "" { byPhone[target.Phone] = target } } upsertsByTarget := make(map[int64]domain.ContactInput, len(targets)) order := make([]int64, 0, len(targets)) seenTargets := map[int64]struct{}{} for _, input := range normalized { target, found := byPhone[input.Phone] if !found || target.ID == userID || target.ID == 0 { continue } input.ContactUserID = target.ID if input.FirstName == "" && input.LastName == "" { input.FirstName = target.FirstName input.LastName = target.LastName } if _, ok := seenTargets[target.ID]; !ok { seenTargets[target.ID] = struct{}{} order = append(order, target.ID) } out.Imported = append(out.Imported, domain.ImportedContact{UserID: target.ID, ClientID: input.ClientID}) upsertsByTarget[target.ID] = input } if len(order) == 0 { return out, nil } upserts := make([]domain.ContactInput, 0, len(order)) for _, targetID := range order { upserts = append(upserts, upsertsByTarget[targetID]) } contacts, err := s.contacts.UpsertMany(ctx, userID, upserts) if err != nil { return domain.ImportContactsResult{}, err } changedIDs := make([]int64, 0, len(upserts)+1) changedIDs = append(changedIDs, userID) for _, input := range upserts { changedIDs = append(changedIDs, input.ContactUserID) } s.InvalidateViewers(changedIDs...) if s.privacy != nil { for _, input := range upserts { if !input.AddPhonePrivacyException || input.ContactUserID == 0 { continue } if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, input.ContactUserID); err != nil { return domain.ImportContactsResult{}, err } } } out.Contacts = append(out.Contacts, contacts...) projected := domain.ContactList{Contacts: out.Contacts} if err := s.projectContactUsers(ctx, userID, &projected); err != nil { return domain.ImportContactsResult{}, err } out.Contacts = projected.Contacts return out, nil } // filterOutUserID drops the given user id from a result slice in place, // preserving order. func filterOutUserID(users []domain.User, excludeID int64) []domain.User { out := users[:0] for _, u := range users { if u.ID != excludeID { out = append(out, u) } } return out } func (s *Service) Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error) { if s == nil || s.users == nil || userID == 0 { return domain.UserSearchResult{}, nil } query = strings.TrimSpace(query) query = strings.TrimPrefix(query, "@") query = strings.TrimSpace(query) if query == "" { return domain.UserSearchResult{}, nil } if limit <= 0 || limit > maxSearchLimit { limit = maxSearchLimit } phoneQuery := "" if isPhoneSearchQuery(query) { phoneQuery = normalizePhoneQuery(query) } res, err := s.users.Search(ctx, userID, query, phoneQuery, limit) if err != nil { return domain.UserSearchResult{}, err } if s.hideThirdPartyVerification { res.MyResults = filterOutUserID(res.MyResults, domain.VerifierBotUserID) res.Results = filterOutUserID(res.Results, domain.VerifierBotUserID) } if s.privacy != nil && phoneQuery != "" && len(res.MyResults)+len(res.Results) > 0 { targetIDs := make([]int64, 0, len(res.MyResults)+len(res.Results)) for _, target := range res.MyResults { if target.ID != 0 && target.ID != userID { targetIDs = append(targetIDs, target.ID) } } for _, target := range res.Results { if target.ID != 0 && target.ID != userID { targetIDs = append(targetIDs, target.ID) } } visibility, err := s.privacy.CanSeeBatch( ctx, targetIDs, userID, []domain.PrivacyKey{domain.PrivacyKeyAddedByPhone}, ) if err != nil { return domain.UserSearchResult{}, err } knownContacts := map[int64]domain.Contact{} if s.contacts != nil && len(targetIDs) > 0 { knownContacts, err = s.contacts.GetMany(ctx, userID, targetIDs) if err != nil { return domain.UserSearchResult{}, err } } allowed := func(target domain.User) bool { if visibility[target.ID][domain.PrivacyKeyAddedByPhone] { return true } contact, found := knownContacts[target.ID] return found && contact.Phone != "" && strings.HasPrefix(contact.Phone, phoneQuery) } res.MyResults = filterSearchUsers(res.MyResults, allowed) res.Results = filterSearchUsers(res.Results, allowed) } return s.projectSearchResult(ctx, userID, res) } func (s *Service) DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) { if s == nil || s.contacts == nil || userID == 0 { return 0, nil } count, err := s.contacts.Delete(ctx, userID, contactUserIDs) if err == nil { ids := make([]int64, 0, len(contactUserIDs)+1) ids = append(ids, userID) ids = append(ids, contactUserIDs...) s.InvalidateViewers(ids...) } return count, err } func (s *Service) EditCloseFriends(ctx context.Context, userID int64, contactUserIDs []int64) (domain.CloseFriendsEditResult, error) { if s == nil || s.contacts == nil || userID == 0 || len(contactUserIDs) > maxCloseFriendsCount { return domain.CloseFriendsEditResult{}, ErrContactIDInvalid } ids := normalizeCloseFriendIDs(userID, contactUserIDs) if s.users != nil && len(ids) > 0 { users, err := s.users.ByIDs(ctx, ids) if err != nil { return domain.CloseFriendsEditResult{}, err } exists := make(map[int64]struct{}, len(users)) for _, user := range users { if user.ID != 0 && !user.Bot { exists[user.ID] = struct{}{} } } filtered := ids[:0] for _, id := range ids { if _, ok := exists[id]; ok { filtered = append(filtered, id) } } ids = filtered } result, err := s.contacts.SetCloseFriends(ctx, userID, ids) if err == nil { s.InvalidateViewers(userID) } return result, err } func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error) { if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 || contactUserID == userID { return domain.Contact{}, ErrContactIDInvalid } contact, found, err := s.contacts.UpdateNote(ctx, userID, contactUserID, note, entities) if err != nil { return domain.Contact{}, err } if !found { return domain.Contact{}, ErrContactIDInvalid } s.InvalidateViewers(userID) return contact, nil } func (s *Service) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photo domain.Photo, date int) (domain.Contact, error) { if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 || contactUserID == userID || photo.ID == 0 { return domain.Contact{}, ErrContactIDInvalid } contact, found, err := s.contacts.SetPersonalPhoto(ctx, userID, contactUserID, photo.ID, date) if err != nil { return domain.Contact{}, err } if !found { return domain.Contact{}, ErrContactReqMissing } s.InvalidateViewers(userID) return s.projectContact(ctx, userID, contact) } func (s *Service) ClearPersonalPhoto(ctx context.Context, userID, contactUserID int64, date int) (domain.Contact, error) { if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 || contactUserID == userID { return domain.Contact{}, ErrContactIDInvalid } contact, found, err := s.contacts.SetPersonalPhoto(ctx, userID, contactUserID, 0, date) if err != nil { return domain.Contact{}, err } if !found { return domain.Contact{}, ErrContactReqMissing } s.InvalidateViewers(userID) return s.projectContact(ctx, userID, contact) } func (s *Service) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { if s == nil || s.contacts == nil || userID == 0 || len(contactUserIDs) == 0 { return map[int64]domain.ProfilePhotoRef{}, nil } return s.contacts.PersonalPhotos(ctx, userID, contactUserIDs) } func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error) { if s == nil || s.contacts == nil || userID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID { return domain.PeerSettings{}, nil } contact, found, err := s.contacts.Get(ctx, userID, peer.ID) if err != nil { return domain.PeerSettings{}, err } blocked, err := s.contacts.IsBlocked(ctx, userID, peer.ID) if err != nil { return domain.PeerSettings{}, err } shareContact := found && !contact.Mutual needContactsException := false if s.privacy != nil { peerCanSeePhone, err := s.peerCanSeeCurrentUserPhone(ctx, userID, peer.ID) if err != nil { return domain.PeerSettings{}, err } needContactsException = !peerCanSeePhone shareContact = found && !peerCanSeePhone } return domain.PeerSettings{ AddContact: !found, BlockContact: !blocked, ShareContact: shareContact, NeedContactsException: needContactsException, }, nil } func (s *Service) peerCanSeeCurrentUserPhone(ctx context.Context, ownerUserID, viewerUserID int64) (bool, error) { allowed, err := s.privacy.CanSee(ctx, ownerUserID, viewerUserID, domain.PrivacyKeyPhoneNumber) if err != nil || allowed { return allowed, err } if s.contacts == nil { return false, nil } contact, found, err := s.contacts.Get(ctx, viewerUserID, ownerUserID) if err != nil { return false, err } // Merely adding the owner by user id does not mean the viewer knows the // owner's phone. Only a non-empty owner-scoped contact phone can suppress the // "share my phone" prompt when PhoneNumber privacy itself denies visibility. return found && contact.Phone != "", nil } // BlockContact adds peer to the current user's blocklist. func (s *Service) BlockContact(ctx context.Context, userID, peerUserID int64, date int) (bool, error) { if s == nil || s.contacts == nil || userID == 0 || peerUserID == 0 || peerUserID == userID { return false, ErrContactIDInvalid } if s.users != nil { if _, found, err := s.users.ByID(ctx, peerUserID); err != nil { return false, err } else if !found { return false, ErrContactIDInvalid } } changed, err := s.contacts.Block(ctx, userID, peerUserID, date) if err == nil { s.InvalidateViewers(userID, peerUserID) } return changed, err } // UnblockContact removes peer from the current user's blocklist. func (s *Service) UnblockContact(ctx context.Context, userID, peerUserID int64) (bool, error) { if s == nil || s.contacts == nil || userID == 0 || peerUserID == 0 || peerUserID == userID { return false, ErrContactIDInvalid } changed, err := s.contacts.Unblock(ctx, userID, peerUserID) if err == nil { s.InvalidateViewers(userID, peerUserID) } return changed, err } // IsBlocked reports whether owner has blocked peer. func (s *Service) IsBlocked(ctx context.Context, userID, peerUserID int64) (bool, error) { if s == nil || s.contacts == nil || userID == 0 || peerUserID == 0 { return false, nil } return s.contacts.IsBlocked(ctx, userID, peerUserID) } // GetBlocked returns a bounded blocked contact page. func (s *Service) GetBlocked(ctx context.Context, userID int64, offset, limit int) (domain.BlockedContactList, error) { if s == nil || s.contacts == nil || userID == 0 { return domain.BlockedContactList{}, nil } if limit <= 0 || limit > 100 { limit = 100 } list, err := s.contacts.ListBlocked(ctx, userID, offset, limit) if err != nil || len(list.Blocked) == 0 || s.projector == nil { return list, err } users := make([]domain.User, len(list.Blocked)) for i := range list.Blocked { users[i] = list.Blocked[i].User } projected, err := s.projector.ForViewer(ctx, userID, users) if err != nil { return domain.BlockedContactList{}, err } for i := range list.Blocked { list.Blocked[i].User = projected[i] } return list, nil } func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) { list, notModified, err := s.GetContacts(ctx, userID, hash) if err != nil || notModified { return nil, notModified, err } ids := make([]int, 0, len(list.Contacts)) for _, contact := range list.Contacts { ids = append(ids, int(contact.User.ID)) } return ids, false, nil } func (s *Service) projectContactUsers(ctx context.Context, userID int64, list *domain.ContactList) error { if s == nil || s.projector == nil || list == nil || len(list.Contacts) == 0 { return nil } users := make([]domain.User, len(list.Contacts)) for i, contact := range list.Contacts { users[i] = contact.User } projected, err := s.projector.ForViewer(ctx, userID, users) if err != nil { return err } for i := range list.Contacts { list.Contacts[i].User = projected[i] } return nil } func (s *Service) projectContact(ctx context.Context, userID int64, contact domain.Contact) (domain.Contact, error) { list := domain.ContactList{Contacts: []domain.Contact{contact}} if err := s.projectContactUsers(ctx, userID, &list); err != nil { return domain.Contact{}, err } if len(list.Contacts) == 0 { return domain.Contact{}, nil } return list.Contacts[0], nil } func (s *Service) projectSearchResult(ctx context.Context, userID int64, res domain.UserSearchResult) (domain.UserSearchResult, error) { if s == nil || s.projector == nil { return res, nil } var err error res.MyResults, err = s.projector.ForViewer(ctx, userID, res.MyResults) if err != nil { return domain.UserSearchResult{}, err } res.Results, err = s.projector.ForViewer(ctx, userID, res.Results) if err != nil { return domain.UserSearchResult{}, err } return res, nil } // digitsOnly 只保留数字字符。保存进 contacts.contact_phone 的号码必须与 users.phone // 一样是不带 "+" 的纯数字:下发时 contact_phone 优先充当 TL user.phone,而客户端展示 // user.phone 时会自行补 "+",任何非数字前缀都会变成 "++<号码>" 这类坏显示。 func digitsOnly(phone string) string { var b strings.Builder b.Grow(len(phone)) for _, r := range phone { if r >= '0' && r <= '9' { b.WriteRune(r) } } return b.String() } func normalizePhoneQuery(phone string) string { if !utf8.ValidString(phone) { return "" } if digits := digitsOnly(phone); digits != "" { return digits } return phone } func isPhoneSearchQuery(query string) bool { query = strings.TrimSpace(query) if query == "" { return false } hasDigit := false for _, r := range query { switch { case r >= '0' && r <= '9': hasDigit = true case r == '+', r == ' ', r == '-', r == '(', r == ')': default: return false } } return hasDigit } func filterSearchUsers(users []domain.User, keep func(domain.User) bool) []domain.User { out := users[:0] for _, user := range users { if keep(user) { out = append(out, user) } } return out } func normalizeCloseFriendIDs(userID int64, ids []int64) []int64 { out := make([]int64, 0, len(ids)) seen := make(map[int64]struct{}, len(ids)) for _, id := range ids { if id <= 0 || id == userID { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } return out }