diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index c28e9651..0df06d12 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -185,11 +185,11 @@ func run(logger *zap.Logger) error { Auth: auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore)), Account: account.NewService(passwordStore, account.WithReactionSettings(passwordStore)), Help: help.NewService(helpStore, helpStore), - Users: users.NewService(userStore, users.WithPhotoProvider(mediaStore)), + Users: users.NewService(userStore, users.WithContactStore(contactStore), users.WithPhotoProvider(mediaStore)), Updates: updates.NewService(updateStateStore, updateEventStore, updates.WithPtsAllocator(ptsAllocator)), Contacts: contacts.NewService(contactStore, userStore), Dialogs: dialogs.NewService(dialogStore, channelStore), - Messages: messageapp.NewService(messageStore, dialogStore), + Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithContactStore(contactStore)), Channels: channelapp.NewService(channelStore), Files: filesService, LangPack: langPackService, diff --git a/docs/compatibility-matrix.md b/docs/compatibility-matrix.md index 43f467e2..69e38bde 100644 --- a/docs/compatibility-matrix.md +++ b/docs/compatibility-matrix.md @@ -18,7 +18,7 @@ status 取值:done(真实实现) / stub(兼容响应) / todo(已发现未实 > PG 集成测试覆盖 AuthKeyStore 与业务 store 往返(TestBusinessStoresRoundTrip)。 > active session 已缓存 raw auth_key_id、业务 auth_key_id、session_id、user_id;router 入口只做一次 temp→perm 与 auth_key→user_id 解析,业务服务按 user_id 查询;普通注册 user_id 从 1780243200 起递增。 > 二阶段私聊文本消息链路已落地:`messages.sendMessage` 事务内写 `private_messages` / 双端 `message_boxes` / `dialogs` / `user_update_events` / `dispatch_outbox`,`pts` 与 owner 视角 `box_id` 由 Redis 原子计数并可从 PG durable log 恢复;outbox worker 批量推送在线 session,链路指标覆盖发送、限流、outbox claim/deliver/fail。 -> 联系人与 dialog owner 视角已补齐:`contacts.addContact/importContacts/deleteContacts/updateContactNote/getContactIDs/getStatuses/search`、dialog pinned/manual unread/peer settings、`messages.*DialogFilter*` 与 `folders.editPeerFolders` 均真实落库;`contacts` 保存每个 owner 对同一 user 的独立姓名/电话/备注,`dialogs` 保存置顶顺序、manual unread、action bar 隐藏态与 archive folder_id,自定义 filters 独立存 `dialog_filters`。 +> 联系人与 dialog owner 视角已补齐:`contacts.addContact/acceptContact/importContacts/deleteContacts/updateContactNote/getContactIDs/getStatuses/search`、dialog pinned/manual unread/peer settings、`messages.*DialogFilter*` 与 `folders.editPeerFolders` 均真实落库;`contacts` 保存每个 owner 对同一 user 的独立姓名/电话/备注,`dialogs` 保存置顶顺序、manual unread、action bar 隐藏态与 archive folder_id,自定义 filters 独立存 `dialog_filters`。 > username 生命周期已补齐:注册仍按 Telegram 语义只创建手机号/姓名账号,后续通过 `account.checkUsername/updateUsername` 设置或清除主 username;PG 以 `lower(username)` partial unique index 防并发占用,`contacts.resolveUsername/resolvePhone` 可用于 TDesktop 搜索/链接解析。 > 注册资料闭环已补齐:TDesktop 手机号验证码后进入 sign-up 页,`auth.signUp` 携带 first_name/last_name 完成建号;主界面 Settings/Profile 的姓名与 bio 变更走 `account.updateProfile`,写入 `users.first_name/last_name/about` 并向其它设备可靠推送 `updateUserName`。 > 联系人 reset、dialog pinned/order/manual unread/peer settings、dialog filters/order/reload、folder peers 已写入账号级 durable update log,并同事务写入 `dispatch_outbox`;离线设备可通过 `updates.getDifference` 恢复这些状态变化,在线其它端由 outbox worker 可靠投递且排除当前 session。 @@ -153,7 +153,7 @@ outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱 | messages.getDialogs | done | real | 从按 user_id HASH 分区的 dialogs/message_boxes/users 查询当前账号私聊会话,并合并 channel_dialogs/channels/channel_messages;支持 exclude_pinned、folder_id(0 主列表/1 归档/2+自定义 filter)、offset_date/offset_id/offset_peer seek pagination、limit、hash notModified;channel offset_peer 校验非零 access_hash;channel dialog 返回持久 `read_outbox_max_id`,可恢复离线/丢失的 `updateReadChannelOutbox`;不会把返回的 channel 宽泛标记为 active viewer,避免 typing/reaction 等瞬时事件误推给全体 dialog 列表;登录后可见 777000 官方系统会话 | | messages.getPinnedDialogs | done | real | 从 dialogs + channel_dialogs 合并查询 pinned 会话,返回 top messages/users/channels,并附持久化 update state | | messages.getPeerDialogs | done | real | 按 InputDialogPeer 精确查询当前账号会话,返回 dialog/top message/users/update state;channel peer 同样返回持久 `read_outbox_max_id`;公开 username channel/supergroup 对非成员返回只读 preview dialog/top message,private/ban/kick/view_messages 不暴露;未建会话的 user peer 返回空 dialog 占位;peer vector cap=100 | -| messages.getPeerSettings | done | real | 当前 owner 视角 peerSettings:非联系人显示 add/block,联系人显示 shareContact;若该 owner 已 hide peer settings bar 则返回空 action bar;附带当前 owner 视角用户资料 | +| messages.getPeerSettings | done | real | 当前 owner 视角 peerSettings:非联系人显示 add/block;当前 owner 已保存对方但双方未 mutual 时显示 shareContact;acceptContact/add/import 形成 mutual 后清除 shareContact;若该 owner 已 hide peer settings bar 则返回空 action bar;附带当前 owner 视角用户资料 | | messages.getMessages | done | real-private | TDesktop reply/webpage/media lazy load 入口;当前按 owner 视角 `InputMessageID` 精确拉取私聊 message_box,ID 不存在返回 messageEmpty,单次最多 100 个 | | messages.getHistory | done | real | 从按 owner_user_id HASH 分区的 message_boxes 按当前账号+peer 查询历史;支持 offset_id、offset_date、add_offset、limit、max_id、min_id、hash,查询条件走分区索引与 seek pagination;`add_offset` clamp 到 `[-100,100]`,避免异常客户端触发超大跳过扫描或内存分配 | | messages.readHistory | done | real-partial | 标记 reader dialog 的 read_inbox_max_id/unread_count 并清 manual unread;真正发生已读时给 reader 生成 updateReadHistoryInbox,同时推进 sender dialog 的 read_outbox_max_id 并生成 updateReadHistoryOutbox;发送消息时不预先推进 read_outbox,避免假已读 | @@ -354,6 +354,7 @@ outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱 | contacts.getStatuses | done | persisted-presence | 返回当前通讯录联系人 `contactStatus`;状态来自运行时 presence、活跃 session 兜底与持久化 `users.last_seen_at`,离线联系人返回精确 `userStatusOffline.was_online`,未知用户退回 `userStatusRecently`。当前未接 account privacy,粗粒度 `recently/lastWeek/lastMonth/empty` 隐私降级仍为后续项 | | contacts.importContacts | done | real | 按手机号匹配已注册用户,写入当前 owner 视角的联系人姓名/电话/备注,返回 imported/users,并写 durable updatePeerSettings + updateContactsReset + dispatch_outbox | | contacts.addContact | done | real | 写入当前 owner 视角联系人资料;禁止 self/空姓名/不存在 user,维护 reverse mutual,并写 durable updatePeerSettings + updateContactsReset + dispatch_outbox | +| contacts.acceptContact | done | real-share-phone | TDesktop “Share my phone number” 入口;要求当前 owner 已有该联系人,否则 CONTACT_REQ_MISSING;把当前用户手机号/姓名写入对方 owner 视角联系人,维护 mutual,返回 updatePeerSettings(shareContact=false)+updateContactsReset,并为双方写 durable peer settings / contacts reset | | contacts.deleteContacts | done | real | 删除当前 owner 的联系人关系,并清理对方 reverse mutual;写 durable updatePeerSettings + updateContactsReset + dispatch_outbox | | contacts.updateContactNote | done | real | 更新当前 owner 对某个联系人的备注与备注实体,不影响其它 owner 对同一 user 的备注,并写 durable updateContactsReset + dispatch_outbox | | contacts.search | done | real | TDesktop 搜索框 peer 分支;strip `@`、空/过短查询报 SEARCH_QUERY_EMPTY/QUERY_TOO_SHORT,limit cap=50;联系人 user 进 MyResults,非联系人 user 进 Results;公开 username channel/supergroup 同步返回 PeerChannel + Chats,当前已加入的放 MyResults,其它公开命中放 Results 并以 left chat 标记只读预览,避免 TDesktop 误显示已加入;用户搜索走手机号前缀/username/姓名/owner 保存姓名索引,公开频道搜索走 username/title trgm 索引 | diff --git a/docs/message-module.md b/docs/message-module.md index 20e54d19..93dfebcd 100644 --- a/docs/message-module.md +++ b/docs/message-module.md @@ -217,7 +217,7 @@ go test ./internal/loadtest/ -run TestMessageSendBaseline -v -count=1 -timeout 3 - active session 缓存 `auth_key_id + user_id`;router 额外按业务 auth_key 缓存 `user_id`,并用 singleflight 合并启动期并发 miss,避免同一永久 auth_key 派生的多个 temp session 重复查授权表。 - `update_states` 主键是 `(auth_key_id, user_id)`;同设备退出登录或换账号只清 auth_key 设备状态,不删除账号级 `user_update_events`。 - 一个账号多设备共享 `user_update_events`,各设备通过自己的 `auth_key_id + user_id` 状态和 `updates.getDifference` 补偿。 -- 联系人备注、dialog pinned/order/manual unread、peer settings 都是 owner 视角数据;写业务表后同步写账号级 durable update event,并把投递任务写入 `dispatch_outbox`,避免“只在线 push、离线设备永远不知道”的状态漂移。 +- 联系人备注、联系人 mutual/shareContact 状态、dialog pinned/order/manual unread、peer settings 都是 owner 视角数据;写业务表后同步写账号级 durable update event,并把投递任务写入 `dispatch_outbox`,避免“只在线 push、离线设备永远不知道”的状态漂移。 ## ACK / Global Sequence Note diff --git a/docs/persistence-layer.md b/docs/persistence-layer.md index 38983448..de7dfbc6 100644 --- a/docs/persistence-layer.md +++ b/docs/persistence-layer.md @@ -141,7 +141,7 @@ internal/store/ 在线 session 需先完成 `updates.getState/getDifference` 才接收主动 updates,未 ready 的推送先暂存; 同时写入账号级 `user_update_events`,`updates.getDifference` 可补偿登录官方消息与官方会话已读事件。—— ✅ - **P4 消息闭环**:私聊文本 `messages.sendMessage/forwardMessages/readHistory/editMessage/deleteMessages/deleteHistory`、reply_to/forward header 元数据、双端 `message_boxes`、已读 inbox/outbox 回执、`user_update_events`、Redis `pts/box_id` allocator、transactional outbox、在线 session 批量推送、窗口限流、链路指标和游标分页已落地;媒体、群组/频道、完整 qts/seq 仍是后续阶段。 -- **P4.1 联系人与 dialog owner 视角**:`contacts.addContact/importContacts/deleteContacts/updateContactNote/getContactIDs/getStatuses` 与 `messages.toggleDialogPin/reorderPinnedDialogs/markDialogUnread/getDialogUnreadMarks/hidePeerSettingsBar/getPeerSettings` 已接入 PG-backed 服务;集成测试覆盖不同 owner 对同一 user 的独立备注、备注 hash、reverse mutual、dialog 用户视角、置顶重排、manual unread 与隐藏 action bar。—— ✅ +- **P4.1 联系人与 dialog owner 视角**:`contacts.addContact/acceptContact/importContacts/deleteContacts/updateContactNote/getContactIDs/getStatuses` 与 `messages.toggleDialogPin/reorderPinnedDialogs/markDialogUnread/getDialogUnreadMarks/hidePeerSettingsBar/getPeerSettings` 已接入 PG-backed 服务;`contacts.acceptContact` 复用联系人反向 upsert,把当前用户手机号/姓名写入对方 owner 视角并清除双方单向联系人 shareContact action;集成测试覆盖不同 owner 对同一 user 的独立备注、备注 hash、reverse mutual、dialog 用户视角、置顶重排、manual unread 与隐藏 action bar。—— ✅ - **P4.2 设置类 durable updates**:联系人 reset、dialog pinned、pinned order、manual unread、peer settings、dialog filters/order/reload 与 folder peers 均写入 `user_update_events`,并同事务写 `dispatch_outbox`;`updates.getDifference` 与 outbox TL 转换支持这些事件,且保存 pinned order / peer settings flags / dialog filter payload / folder peers / exclude auth key,在线 push 不再是唯一通知路径,也不会与 reliable outbox 双重推送。—— ✅ - **P4.3 删除消息/清空历史**:`messages.deleteMessages/deleteHistory` 已支持 owner 视角软删除、`revoke` 对端清理、dialog top 重算/删除/`just_clear` 保留空 dialog、`updateDeleteMessages` durable payload(`message_ids` + `pts_count=len(message_ids)`)与 outbox 投递;后续新消息会正常重建被删除的 dialog。—— ✅ - **P4.4 Dialog 分组/归档**:`messages.getDialogFilters/updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTags` 与 `folders.editPeerFolders` 已接入 PG-backed 服务;集成测试覆盖 folder_id 0/1 主列表/归档、自定义 filter、tags 和归档还原。—— ✅ diff --git a/internal/app/contacts/service.go b/internal/app/contacts/service.go index 237af230..fcfff778 100644 --- a/internal/app/contacts/service.go +++ b/internal/app/contacts/service.go @@ -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 } diff --git a/internal/app/contacts/service_test.go b/internal/app/contacts/service_test.go index dcca953b..29128073 100644 --- a/internal/app/contacts/service_test.go +++ b/internal/app/contacts/service_test.go @@ -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) + } +} diff --git a/internal/app/messages/service.go b/internal/app/messages/service.go index 0193d2ef..4c511c91 100644 --- a/internal/app/messages/service.go +++ b/internal/app/messages/service.go @@ -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 } diff --git a/internal/app/messages/service_test.go b/internal/app/messages/service_test.go new file mode 100644 index 00000000..8737d94d --- /dev/null +++ b/internal/app/messages/service_test.go @@ -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 +} diff --git a/internal/app/userprojection/projection.go b/internal/app/userprojection/projection.go new file mode 100644 index 00000000..300f4b24 --- /dev/null +++ b/internal/app/userprojection/projection.go @@ -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 +} diff --git a/internal/app/users/service.go b/internal/app/users/service.go index 06f2f7ca..a2c8238e 100644 --- a/internal/app/users/service.go +++ b/internal/app/users/service.go @@ -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 { diff --git a/internal/app/users/service_test.go b/internal/app/users/service_test.go index 17a69ae6..64c56701 100644 --- a/internal/app/users/service_test.go +++ b/internal/app/users/service_test.go @@ -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 diff --git a/internal/rpc/contacts.go b/internal/rpc/contacts.go index 4f75edb6..8ecbf730 100644 --- a/internal/rpc/contacts.go +++ b/internal/rpc/contacts.go @@ -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() } diff --git a/internal/rpc/convert.go b/internal/rpc/convert.go index d5ad29f3..e339632f 100644 --- a/internal/rpc/convert.go +++ b/internal/rpc/convert.go @@ -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 diff --git a/internal/rpc/convert_test.go b/internal/rpc/convert_test.go new file mode 100644 index 00000000..f6e41b60 --- /dev/null +++ b/internal/rpc/convert_test.go @@ -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) + } +} diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 0b913220..0faea1d4 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -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) diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index 13a83a97..854ba958 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -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") } diff --git a/internal/rpc/messages.go b/internal/rpc/messages.go index f8841bc7..ad6f35b7 100644 --- a/internal/rpc/messages.go +++ b/internal/rpc/messages.go @@ -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, diff --git a/internal/rpc/router_test.go b/internal/rpc/router_test.go index 8d50c761..069cfcc2 100644 --- a/internal/rpc/router_test.go +++ b/internal/rpc/router_test.go @@ -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"}