business: fix contact projection and phone sharing

(cherry picked from commit c0a0e5b52240ed415d3b43ba77659821887bf50b)
This commit is contained in:
A 2026-06-07 23:36:06 +08:00
parent d84fa6e126
commit 860e581d06
18 changed files with 703 additions and 36 deletions

View file

@ -11,8 +11,9 @@ import (
)
var (
ErrContactIDInvalid = errors.New("contact id invalid")
ErrContactNameEmpty = errors.New("contact name empty")
ErrContactIDInvalid = errors.New("contact id invalid")
ErrContactNameEmpty = errors.New("contact name empty")
ErrContactReqMissing = errors.New("contact request missing")
)
const maxSearchLimit = 50
@ -112,6 +113,54 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
return contact, nil
}
// AcceptContact shares the current user's phone/profile with an existing one-way contact.
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
}
contact, found, err := s.contacts.Get(ctx, userID, target.ID)
if err != nil {
return domain.Contact{}, err
}
if !found {
return domain.Contact{}, ErrContactReqMissing
}
return contact, nil
}
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
@ -225,7 +274,7 @@ func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain
if s == nil || s.contacts == nil || userID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID {
return domain.PeerSettings{}, nil
}
_, found, err := s.contacts.Get(ctx, userID, peer.ID)
contact, found, err := s.contacts.Get(ctx, userID, peer.ID)
if err != nil {
return domain.PeerSettings{}, err
}
@ -236,7 +285,7 @@ func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain
return domain.PeerSettings{
AddContact: !found,
BlockContact: !blocked,
ShareContact: found,
ShareContact: found && !contact.Mutual,
}, nil
}

View file

@ -2,6 +2,7 @@ package contacts
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
@ -42,3 +43,90 @@ func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) {
t.Fatalf("contact first name = %q, want final input", res.Contacts[0].FirstName)
}
}
func TestAcceptContactSharesPhoneAndClearsShareContact(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
contactsStore := memory.NewContactStore()
alice, err := users.Create(ctx, domain.User{Phone: "15550000001", FirstName: "Alice", LastName: "A"})
if err != nil {
t.Fatalf("create alice: %v", err)
}
bob, err := users.Create(ctx, domain.User{Phone: "15550000002", FirstName: "Bob", LastName: "B"})
if err != nil {
t.Fatalf("create bob: %v", err)
}
svc := NewService(contactsStore, users)
if _, err := svc.AddContact(ctx, alice.ID, domain.ContactInput{
ContactUserID: bob.ID,
Phone: bob.Phone,
FirstName: "Bobby",
LastName: "Remark",
}); err != nil {
t.Fatalf("alice add bob: %v", err)
}
settings, err := svc.GetPeerSettings(ctx, alice.ID, domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID})
if err != nil {
t.Fatalf("alice peer settings before accept: %v", err)
}
if !settings.ShareContact {
t.Fatalf("alice settings before accept = %+v, want share contact", settings)
}
contact, err := svc.AcceptContact(ctx, alice.ID, bob.ID)
if err != nil {
t.Fatalf("AcceptContact: %v", err)
}
if !contact.Mutual || !contact.User.Mutual {
t.Fatalf("accepted contact = %+v, want mutual", contact)
}
aliceSettings, err := svc.GetPeerSettings(ctx, alice.ID, domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID})
if err != nil {
t.Fatalf("alice peer settings after accept: %v", err)
}
if aliceSettings.ShareContact || aliceSettings.AddContact {
t.Fatalf("alice settings after accept = %+v, want no share/add", aliceSettings)
}
bobSettings, err := svc.GetPeerSettings(ctx, bob.ID, domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID})
if err != nil {
t.Fatalf("bob peer settings after accept: %v", err)
}
if bobSettings.ShareContact || bobSettings.AddContact {
t.Fatalf("bob settings after accept = %+v, want no share/add", bobSettings)
}
reverse, found, err := contactsStore.Get(ctx, bob.ID, alice.ID)
if err != nil || !found {
t.Fatalf("bob contact alice found=%v err=%v", found, err)
}
if reverse.Phone != alice.Phone || reverse.FirstName != alice.FirstName || reverse.LastName != alice.LastName || !reverse.Mutual {
t.Fatalf("bob contact alice = %+v, want alice phone/name and mutual", reverse)
}
repeated, err := svc.AcceptContact(ctx, alice.ID, bob.ID)
if err != nil {
t.Fatalf("AcceptContact repeat: %v", err)
}
if !repeated.Mutual {
t.Fatalf("repeated accept = %+v, want mutual", repeated)
}
}
func TestAcceptContactRequiresExistingContactRequest(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
contactsStore := memory.NewContactStore()
alice, err := users.Create(ctx, domain.User{Phone: "15550000001", FirstName: "Alice"})
if err != nil {
t.Fatalf("create alice: %v", err)
}
bob, err := users.Create(ctx, domain.User{Phone: "15550000002", FirstName: "Bob"})
if err != nil {
t.Fatalf("create bob: %v", err)
}
svc := NewService(contactsStore, users)
if _, err := svc.AcceptContact(ctx, alice.ID, bob.ID); !errors.Is(err, ErrContactReqMissing) {
t.Fatalf("AcceptContact without contact err = %v, want ErrContactReqMissing", err)
}
}

View file

@ -3,6 +3,7 @@ package messages
import (
"context"
"telesrv/internal/app/userprojection"
"telesrv/internal/domain"
"telesrv/internal/store"
)
@ -11,11 +12,24 @@ import (
type Service struct {
messages store.MessageStore
dialogs store.DialogStore
contacts store.ContactStore
}
// Option adjusts optional message service dependencies.
type Option func(*Service)
// WithContactStore enables viewer-specific user projection for message history.
func WithContactStore(c store.ContactStore) Option {
return func(s *Service) { s.contacts = c }
}
// NewService 创建 messages 服务。
func NewService(messages store.MessageStore, dialogs store.DialogStore) *Service {
return &Service{messages: messages, dialogs: dialogs}
func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...Option) *Service {
s := &Service{messages: messages, dialogs: dialogs}
for _, opt := range opts {
opt(s)
}
return s
}
// SendPrivateText 发送一条私聊文本消息。
@ -45,7 +59,11 @@ func (s *Service) GetMessages(ctx context.Context, userID int64, ids []int) (dom
if s == nil || s.messages == nil || userID == 0 || len(ids) == 0 {
return domain.MessageList{}, nil
}
return s.messages.GetByIDs(ctx, userID, ids)
list, err := s.messages.GetByIDs(ctx, userID, ids)
if err != nil {
return domain.MessageList{}, err
}
return s.projectMessageUsers(ctx, userID, list)
}
// GetHistory 返回当前账号某个 peer 的历史消息。
@ -176,5 +194,18 @@ func (s *Service) list(ctx context.Context, userID int64, filter domain.MessageF
if s == nil || s.messages == nil || userID == 0 {
return domain.MessageList{}, nil
}
return s.messages.ListByUser(ctx, userID, filter)
list, err := s.messages.ListByUser(ctx, userID, filter)
if err != nil {
return domain.MessageList{}, err
}
return s.projectMessageUsers(ctx, userID, list)
}
func (s *Service) projectMessageUsers(ctx context.Context, userID int64, list domain.MessageList) (domain.MessageList, error) {
users, err := userprojection.ForViewer(ctx, s.contacts, userID, list.Users)
if err != nil {
return domain.MessageList{}, err
}
list.Users = users
return list, nil
}

View file

@ -0,0 +1,117 @@
package messages
import (
"context"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1001
const friendID int64 = 1002
const strangerID int64 = 1003
contacts := memory.NewContactStore()
if _, err := contacts.Upsert(ctx, ownerID, domain.ContactInput{
ContactUserID: friendID,
Phone: "15550000002",
FirstName: "Remark",
LastName: "Friend",
}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
store := projectionMessageStore{list: domain.MessageList{
Users: []domain.User{
{ID: ownerID, Phone: "15550000001", FirstName: "Owner"},
{ID: friendID, AccessHash: 22, Phone: "15550000002", FirstName: "Public", LastName: "Name"},
{ID: strangerID, AccessHash: 33, Phone: "15550000003", FirstName: "Stranger"},
},
}}
svc := NewService(store, nil, WithContactStore(contacts))
list, err := svc.GetHistory(ctx, ownerID, domain.MessageFilter{Limit: 10})
if err != nil {
t.Fatalf("GetHistory: %v", err)
}
friend := findUser(t, list.Users, friendID)
if !friend.Contact || friend.FirstName != "Remark" || friend.LastName != "Friend" || friend.Phone != "15550000002" {
t.Fatalf("friend projection = %+v, want contact remark and phone", friend)
}
stranger := findUser(t, list.Users, strangerID)
if stranger.Contact || stranger.Phone != "" || stranger.FirstName != "Stranger" {
t.Fatalf("stranger projection = %+v, want non-contact with hidden phone", stranger)
}
self := findUser(t, list.Users, ownerID)
if self.Phone != "15550000001" {
t.Fatalf("self phone = %q, want preserved", self.Phone)
}
}
func findUser(t *testing.T, users []domain.User, id int64) domain.User {
t.Helper()
for _, user := range users {
if user.ID == id {
return user
}
}
t.Fatalf("user %d not found in %+v", id, users)
return domain.User{}
}
type projectionMessageStore struct {
list domain.MessageList
}
func (s projectionMessageStore) Create(context.Context, domain.Message) (domain.Message, error) {
return domain.Message{}, nil
}
func (s projectionMessageStore) SendPrivateText(context.Context, domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
return domain.SendPrivateTextResult{}, nil
}
func (s projectionMessageStore) ForwardPrivateMessages(context.Context, domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) {
return domain.ForwardPrivateMessagesResult{}, nil
}
func (s projectionMessageStore) ReadHistory(context.Context, domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) {
return domain.ReadHistoryResult{}, nil
}
func (s projectionMessageStore) ReadMessageContents(context.Context, domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) {
return domain.ReadMessageContentsResult{}, nil
}
func (s projectionMessageStore) GetOutboxReadDate(context.Context, domain.OutboxReadDateRequest) (int, error) {
return 0, nil
}
func (s projectionMessageStore) SetMessageReactions(context.Context, domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
return domain.PrivateMessageReactionsResult{}, nil
}
func (s projectionMessageStore) GetMessageReactions(context.Context, domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
return domain.PrivateMessageReactionsResult{}, nil
}
func (s projectionMessageStore) EditMessage(context.Context, domain.EditMessageRequest) (domain.EditMessageResult, error) {
return domain.EditMessageResult{}, nil
}
func (s projectionMessageStore) DeleteMessages(context.Context, domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) {
return domain.DeleteMessagesResult{}, nil
}
func (s projectionMessageStore) DeleteHistory(context.Context, domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) {
return domain.DeleteMessagesResult{}, nil
}
func (s projectionMessageStore) GetByIDs(context.Context, int64, []int) (domain.MessageList, error) {
return s.list, nil
}
func (s projectionMessageStore) ListByUser(context.Context, int64, domain.MessageFilter) (domain.MessageList, error) {
return s.list, nil
}

View file

@ -0,0 +1,75 @@
package userprojection
import (
"context"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// 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 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
}

View file

@ -6,6 +6,7 @@ import (
"strings"
"unicode/utf8"
"telesrv/internal/app/userprojection"
"telesrv/internal/domain"
"telesrv/internal/store"
)
@ -20,8 +21,9 @@ type ProfilePhotoProvider interface {
// Service 提供用户查询。
type Service struct {
users store.UserStore
photos ProfilePhotoProvider
users store.UserStore
contacts store.ContactStore
photos ProfilePhotoProvider
}
// Option 调整用户服务可选依赖。
@ -32,6 +34,11 @@ func WithPhotoProvider(p ProfilePhotoProvider) Option {
return func(s *Service) { s.photos = p }
}
// WithContactStore enables viewer-specific contact name/phone projection.
func WithContactStore(c store.ContactStore) Option {
return func(s *Service) { s.contacts = c }
}
const (
minUsernameLen = 5
maxUsernameLen = 32
@ -85,7 +92,12 @@ func (s *Service) ByID(ctx context.Context, currentUserID, userID int64) (domain
if !found {
return u, false, nil
}
return s.enrichOne(ctx, u), true, nil
u = s.enrichOne(ctx, u)
u, err = userprojection.One(ctx, s.contacts, currentUserID, u)
if err != nil {
return domain.User{}, false, err
}
return u, true, nil
}
// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
@ -115,7 +127,8 @@ func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int6
if err != nil {
return nil, err
}
return s.enrich(ctx, users), nil
users = s.enrich(ctx, users)
return userprojection.ForViewer(ctx, s.contacts, currentUserID, users)
}
// enrich 批量把当前头像富化到用户列表(best-effort:失败不影响用户查询)。
@ -248,7 +261,12 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
if err != nil || !found {
return u, found, err
}
return s.enrichOne(ctx, u), true, nil
u = s.enrichOne(ctx, u)
u, err = userprojection.One(ctx, s.contacts, currentUserID, u)
if err != nil {
return domain.User{}, false, err
}
return u, true, nil
}
// ResolvePhone 解析手机号到用户;当前阶段默认允许手机号深链解析,隐私规则后续接 account privacy。
@ -264,7 +282,12 @@ func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone s
if err != nil || !found {
return u, found, err
}
return s.enrichOne(ctx, u), true, nil
u = s.enrichOne(ctx, u)
u, err = userprojection.One(ctx, s.contacts, currentUserID, u)
if err != nil {
return domain.User{}, false, err
}
return u, true, nil
}
func normalizeUsername(username string) string {

View file

@ -117,6 +117,48 @@ func TestServiceByIDDoesNotReloadSelf(t *testing.T) {
}
}
func TestServiceProjectsUsersForViewerContacts(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
contacts := memory.NewContactStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
friend, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Public", LastName: "Name"})
if err != nil {
t.Fatalf("create friend: %v", err)
}
stranger, err := userStore.Create(ctx, domain.User{AccessHash: 3, Phone: "15550000003", FirstName: "Stranger"})
if err != nil {
t.Fatalf("create stranger: %v", err)
}
if _, err := contacts.Upsert(ctx, owner.ID, domain.ContactInput{
ContactUserID: friend.ID,
Phone: "15550000002",
FirstName: "Remark",
LastName: "Friend",
}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
svc := NewService(userStore, WithContactStore(contacts))
contactUser, found, err := svc.ByID(ctx, owner.ID, friend.ID)
if err != nil || !found {
t.Fatalf("ByID contact found=%v err=%v", found, err)
}
if !contactUser.Contact || contactUser.FirstName != "Remark" || contactUser.LastName != "Friend" || contactUser.Phone != "15550000002" {
t.Fatalf("projected contact = %+v, want contact remark and phone", contactUser)
}
nonContact, found, err := svc.ByID(ctx, owner.ID, stranger.ID)
if err != nil || !found {
t.Fatalf("ByID non-contact found=%v err=%v", found, err)
}
if nonContact.Contact || nonContact.Phone != "" || nonContact.FirstName != "Stranger" {
t.Fatalf("projected non-contact = %+v, want name with hidden phone", nonContact)
}
}
type countingUserStore struct {
*memory.UserStore
byIDCalls int

View file

@ -30,6 +30,7 @@ func (r *Router) registerContacts(d *tg.ServerDispatcher) {
d.OnContactsGetStatuses(r.onContactsGetStatuses)
d.OnContactsImportContacts(r.onContactsImportContacts)
d.OnContactsAddContact(r.onContactsAddContact)
d.OnContactsAcceptContact(r.onContactsAcceptContact)
d.OnContactsDeleteContacts(r.onContactsDeleteContacts)
d.OnContactsBlock(r.onContactsBlock)
d.OnContactsUnblock(r.onContactsUnblock)
@ -263,9 +264,19 @@ func (r *Router) onContactsImportContacts(ctx context.Context, input []tg.InputP
}
out.RetryContacts = append(out.RetryContacts, res.RetryContacts...)
for _, contact := range res.Contacts {
if err := r.recordPeerSettings(ctx, userID, domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}, domain.PeerSettings{ShareContact: true}); err != nil {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}
settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer)
if err != nil {
return nil, internalErr()
}
if err := r.recordPeerSettings(ctx, userID, peer, settings); err != nil {
return nil, internalErr()
}
if contact.Mutual {
if err := r.recordAcceptedContactTargetUpdates(ctx, userID, contact.User.ID); err != nil {
return nil, err
}
}
}
if err := r.recordContactsReset(ctx, userID); err != nil {
return nil, internalErr()
@ -305,14 +316,85 @@ func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddCo
if err != nil {
return nil, contactErr(err)
}
updates := r.contactPeerSettingsUpdates(ctx, userID, contact.User, domain.PeerSettings{ShareContact: true}, true)
peerUser := contact.User
peerUser.Contact = true
peerUser.Mutual = contact.Mutual || contact.User.Mutual
if contact.Phone != "" {
peerUser.Phone = contact.Phone
}
if contact.FirstName != "" || contact.LastName != "" {
peerUser.FirstName = contact.FirstName
peerUser.LastName = contact.LastName
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}
settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer)
if err != nil {
return nil, internalErr()
}
updates := r.contactPeerSettingsUpdates(ctx, userID, peerUser, settings, true)
updates.Updates = append(updates.Updates, &tg.UpdateContactsReset{})
if err := r.recordPeerSettings(ctx, userID, domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}, domain.PeerSettings{ShareContact: true}); err != nil {
if err := r.recordPeerSettings(ctx, userID, peer, settings); err != nil {
return nil, internalErr()
}
if err := r.recordContactsReset(ctx, userID); err != nil {
return nil, internalErr()
}
if contact.Mutual {
if err := r.recordAcceptedContactTargetUpdates(ctx, userID, contact.User.ID); err != nil {
return nil, err
}
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, updates)
return updates, nil
}
func (r *Router) onContactsAcceptContact(ctx context.Context, id tg.InputUserClass) (tg.UpdatesClass, error) {
if r.deps.Contacts == nil {
return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
target, found, err := r.userFromInput(ctx, userID, id)
if err != nil {
return nil, contactErr(err)
}
if !found || target.ID == userID {
return nil, contactIDInvalidErr()
}
contact, err := r.deps.Contacts.AcceptContact(ctx, userID, target.ID)
if err != nil {
return nil, contactErr(err)
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}
settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer)
if err != nil {
return nil, internalErr()
}
peerUser := contact.User
peerUser.Contact = true
peerUser.Mutual = contact.Mutual || contact.User.Mutual
if contact.Phone != "" {
peerUser.Phone = contact.Phone
}
if contact.FirstName != "" || contact.LastName != "" {
peerUser.FirstName = contact.FirstName
peerUser.LastName = contact.LastName
}
updates := r.contactPeerSettingsUpdates(ctx, userID, peerUser, settings, true)
updates.Updates = append(updates.Updates, &tg.UpdateContactsReset{})
if err := r.recordPeerSettings(ctx, userID, peer, settings); err != nil {
return nil, internalErr()
}
if err := r.recordContactsReset(ctx, userID); err != nil {
return nil, internalErr()
}
if err := r.recordAcceptedContactTargetUpdates(ctx, userID, target.ID); err != nil {
return nil, err
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, updates)
return updates, nil
}
@ -562,6 +644,38 @@ func (r *Router) contactPeerSettingsUpdates(ctx context.Context, userID int64, p
}
}
func (r *Router) recordAcceptedContactTargetUpdates(ctx context.Context, userID, targetUserID int64) error {
if targetUserID == 0 || targetUserID == userID {
return nil
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
settings, err := r.deps.Contacts.GetPeerSettings(ctx, targetUserID, peer)
if err != nil {
return internalErr()
}
var zeroAuthKeyID [8]byte
if err := r.recordPeerSettingsForUser(ctx, zeroAuthKeyID, targetUserID, peer, settings, 0); err != nil {
return internalErr()
}
if err := r.recordContactsResetForUser(ctx, zeroAuthKeyID, targetUserID, 0); err != nil {
return internalErr()
}
peerUser := domain.User{ID: userID}
if r.deps.Users != nil {
u, found, err := r.deps.Users.ByID(ctx, targetUserID, userID)
if err != nil {
return internalErr()
}
if found {
peerUser = u
}
}
updates := r.contactPeerSettingsUpdates(ctx, targetUserID, peerUser, settings, true)
updates.Updates = append(updates.Updates, &tg.UpdateContactsReset{})
r.pushUserUpdatesIfNoReliableDispatch(ctx, targetUserID, updates)
return nil
}
func (r *Router) pushContactsReset(ctx context.Context, userID int64) {
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateContactsReset{}},
@ -571,22 +685,30 @@ func (r *Router) pushContactsReset(ctx context.Context, userID int64) {
}
func (r *Router) recordContactsReset(ctx context.Context, userID int64) error {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
return r.recordContactsResetForUser(ctx, authKeyID, userID, sessionID)
}
func (r *Router) recordContactsResetForUser(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) error {
if r.deps.Updates == nil || userID == 0 {
return nil
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
_, _, err := r.deps.Updates.RecordContactsReset(ctx, authKeyID, userID, sessionID)
_, _, err := r.deps.Updates.RecordContactsReset(ctx, authKeyID, userID, excludeSessionID)
return err
}
func (r *Router) recordPeerSettings(ctx context.Context, userID int64, peer domain.Peer, settings domain.PeerSettings) error {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
return r.recordPeerSettingsForUser(ctx, authKeyID, userID, peer, settings, sessionID)
}
func (r *Router) recordPeerSettingsForUser(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) error {
if r.deps.Updates == nil || userID == 0 {
return nil
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
_, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, sessionID)
_, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, excludeSessionID)
return err
}
@ -628,6 +750,8 @@ func contactErr(err error) error {
return contactNameEmptyErr()
case errors.Is(err, contacts.ErrContactIDInvalid):
return contactIDInvalidErr()
case errors.Is(err, contacts.ErrContactReqMissing):
return contactReqMissingErr()
default:
return internalErr()
}

View file

@ -231,7 +231,7 @@ func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDi
messages = append(messages, item)
}
}
users := tgUsers(list.Users)
users := tgUsersForViewer(viewerUserID, list.Users)
chats := tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs)
if list.Count > len(dialogs) {
return &tg.MessagesDialogsSlice{
@ -250,14 +250,14 @@ func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDi
}
}
func tgMessagesMessages(list domain.MessageList) tg.MessagesMessagesClass {
func tgMessagesMessages(viewerUserID int64, list domain.MessageList) tg.MessagesMessagesClass {
messages := make([]tg.MessageClass, 0, len(list.Messages))
for _, msg := range list.Messages {
if item := tgMessage(msg); item != nil {
messages = append(messages, item)
}
}
users := tgUsers(list.Users)
users := tgUsersForViewer(viewerUserID, list.Users)
if list.Count > len(messages) {
return &tg.MessagesMessagesSlice{
Count: list.Count,
@ -279,6 +279,18 @@ func tgUsers(users []domain.User) []tg.UserClass {
return out
}
func tgUsersForViewer(viewerUserID int64, users []domain.User) []tg.UserClass {
out := make([]tg.UserClass, 0, len(users))
for _, u := range users {
if viewerUserID != 0 && u.ID == viewerUserID {
out = append(out, tgSelfUser(u))
continue
}
out = append(out, tgUser(u))
}
return out
}
func tgPeerDialogs(viewerUserID int64, list domain.DialogList, st domain.UpdateState) *tg.MessagesPeerDialogs {
out := &tg.MessagesPeerDialogs{
Dialogs: make([]tg.DialogClass, 0, len(list.Dialogs)),
@ -303,7 +315,11 @@ func tgPeerDialogs(viewerUserID int64, list domain.DialogList, st domain.UpdateS
}
}
for _, u := range list.Users {
out.Users = append(out.Users, tgUser(u))
if viewerUserID != 0 && u.ID == viewerUserID {
out.Users = append(out.Users, tgSelfUser(u))
} else {
out.Users = append(out.Users, tgUser(u))
}
}
out.Chats = append(out.Chats, tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs)...)
return out

View file

@ -0,0 +1,31 @@
package rpc
import (
"testing"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func TestTGMessagesMessagesMarksViewerSelfAndKeepsProjectedPhone(t *testing.T) {
const viewerID int64 = 1001
res := tgMessagesMessages(viewerID, domain.MessageList{
Users: []domain.User{
{ID: viewerID, AccessHash: 11, Phone: "15550000001", FirstName: "Owner"},
{ID: 1002, AccessHash: 22, Phone: "", FirstName: "Peer"},
},
})
full, ok := res.(*tg.MessagesMessages)
if !ok {
t.Fatalf("result = %T, want *tg.MessagesMessages", res)
}
self, ok := full.Users[0].(*tg.User)
if !ok || !self.Self || self.Phone != "15550000001" {
t.Fatalf("self user = %+v ok=%v, want self with phone", full.Users[0], ok)
}
peer, ok := full.Users[1].(*tg.User)
if !ok || peer.Self || peer.Phone != "" || peer.FirstName != "Peer" {
t.Fatalf("peer user = %+v ok=%v, want projected non-self without phone", full.Users[1], ok)
}
}

View file

@ -133,6 +133,7 @@ type ContactsService interface {
GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error)
ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error)
AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error)
AcceptContact(ctx context.Context, userID, contactUserID int64) (domain.Contact, error)
ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error)
Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error)
DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error)

View file

@ -126,6 +126,8 @@ func contactIDInvalidErr() error { return tgerr.New(400, "CONTACT_ID_INVALID") }
func contactNameEmptyErr() error { return tgerr.New(400, "CONTACT_NAME_EMPTY") }
func contactReqMissingErr() error { return tgerr.New(400, "CONTACT_REQ_MISSING") }
// messageEmptyErr 表示发送空文本。
func messageEmptyErr() error { return tgerr.New(400, "MESSAGE_EMPTY") }

View file

@ -352,7 +352,7 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
if filter.Hash != 0 && list.Hash == filter.Hash {
return &tg.MessagesMessagesNotModified{Count: list.Count}, nil
}
return tgMessagesMessages(r.withMessageListPresence(list)), nil
return tgMessagesMessages(userID, r.withMessageListPresence(list)), nil
})
d.OnMessagesReadHistory(func(ctx context.Context, req *tg.MessagesReadHistoryRequest) (*tg.MessagesAffectedMessages, error) {
id, _ := AuthKeyIDFrom(ctx)
@ -463,7 +463,7 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
if filter.Hash != 0 && list.Hash == filter.Hash {
return &tg.MessagesMessagesNotModified{Count: list.Count}, nil
}
return tgMessagesMessages(r.withMessageListPresence(list)), nil
return tgMessagesMessages(userID, r.withMessageListPresence(list)), nil
})
d.OnMessagesSearchGlobal(r.onMessagesSearchGlobal)
d.OnMessagesGetSearchResultsCalendar(r.onMessagesGetSearchResultsCalendar)
@ -3792,7 +3792,7 @@ func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSea
}
}
if req.UsersOnly || r.deps.Channels == nil {
return tgMessagesMessages(r.withMessageListPresence(limitMessageList(private, limit))), nil
return tgMessagesMessages(userID, r.withMessageListPresence(limitMessageList(private, limit))), nil
}
channelHistory, err := r.deps.Channels.SearchJoinedMessages(ctx, userID, domain.ChannelGlobalSearchRequest{
Query: query,

View file

@ -8030,6 +8030,73 @@ func TestContactsStatusesAndContactsUsePresence(t *testing.T) {
}
}
func TestContactsAcceptContactReturnsSettingsAndReset(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
contactsStore := memory.NewContactStore()
alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice", LastName: "A"})
if err != nil {
t.Fatalf("create alice: %v", err)
}
bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob", LastName: "B"})
if err != nil {
t.Fatalf("create bob: %v", err)
}
contactsSvc := appcontacts.NewService(contactsStore, userStore)
if _, err := contactsSvc.AddContact(ctx, alice.ID, domain.ContactInput{
ContactUserID: bob.ID,
Phone: bob.Phone,
FirstName: "Bobby",
LastName: "Remark",
}); err != nil {
t.Fatalf("alice add bob: %v", err)
}
updatesSvc := &captureUpdates{state: domain.UpdateState{Pts: 10, Date: 1700000400}}
r := New(Config{}, Deps{
Contacts: contactsSvc,
Users: appusers.NewService(userStore, appusers.WithContactStore(contactsStore)),
Updates: updatesSvc,
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000400, 0)})
out, err := r.onContactsAcceptContact(WithUserID(ctx, alice.ID), &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash})
if err != nil {
t.Fatalf("contacts.acceptContact: %v", err)
}
got, ok := out.(*tg.Updates)
if !ok {
t.Fatalf("updates = %T, want *tg.Updates", out)
}
if len(got.Updates) != 2 {
t.Fatalf("updates = %+v, want peer settings + contacts reset", got.Updates)
}
settings, ok := got.Updates[0].(*tg.UpdatePeerSettings)
if !ok {
t.Fatalf("update[0] = %T, want UpdatePeerSettings", got.Updates[0])
}
if settings.Settings.ShareContact || settings.Settings.AddContact {
t.Fatalf("peer settings = %+v, want share/add false", settings.Settings)
}
if _, ok := got.Updates[1].(*tg.UpdateContactsReset); !ok {
t.Fatalf("update[1] = %T, want UpdateContactsReset", got.Updates[1])
}
if len(updatesSvc.events) != 4 {
t.Fatalf("recorded events = %+v, want current peer/reset and target peer/reset", updatesSvc.events)
}
if updatesSvc.events[0].UserID != alice.ID || updatesSvc.events[0].Settings.ShareContact {
t.Fatalf("current peer settings event = %+v, want alice share=false", updatesSvc.events[0])
}
if updatesSvc.events[2].UserID != bob.ID || updatesSvc.events[2].Settings.ShareContact {
t.Fatalf("target peer settings event = %+v, want bob share=false", updatesSvc.events[2])
}
reverse, found, err := contactsStore.Get(ctx, bob.ID, alice.ID)
if err != nil || !found {
t.Fatalf("bob contact alice found=%v err=%v", found, err)
}
if reverse.Phone != alice.Phone || !reverse.Mutual {
t.Fatalf("bob contact alice = %+v, want shared phone and mutual", reverse)
}
}
func TestContactsStatusesUsesOnlineSessionFallback(t *testing.T) {
ctx := context.Background()
alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"}