diff --git a/docs/compatibility-matrix.md b/docs/compatibility-matrix.md index 69e38bde..3cc99ef3 100644 --- a/docs/compatibility-matrix.md +++ b/docs/compatibility-matrix.md @@ -106,7 +106,8 @@ status 取值:done(真实实现) / stub(兼容响应) / todo(已发现未实 | account.getAccountTTL | stub | default-365d | TDesktop Settings/self-destruct 预取;当前不做账号自动销毁持久配置,返回正数默认 TTL 避免后台 NOT_IMPLEMENTED | | account.getNotifySettings | stub | default | TDesktop 主界面读取通知设置;显式返回 show_previews=true、silent=false、mute_until=0 与 default sound,避免空 settings 被客户端按静默展示 | | account.updateNotifySettings | stub | ok | 第一阶段不持久化通知偏好,但接受 TDesktop 设置写入,避免本地通知状态 RPC 报错 | -| account.getPrivacy | stub | default | Settings/Folders 预取隐私项;参考实现 默认规则:手机号默认 disallowAll、生日 allowContacts、其它 allowAll | +| account.getPrivacy | done | real-privacy | 按 owner+key 读取 `account_privacy_rules`;无记录时默认 PhoneNumber=DisallowAll、Birthday=AllowContacts、其它=AllowAll;支持 Layer225 全部 privacy key/rule kind,users/chats 按规则回填 | +| account.setPrivacy | done | real-privacy/online-update | 校验 `InputPrivacyKey/InputPrivacyRule` 与 input user access_hash 后持久化;返回 `account.privacyRules` 并向当前账号在线 session 推 `updatePrivacy`;Stars/Premium/Bot 等高级产品只保存规则,不扩展商业模型。离线 `updates.getDifference` 恢复 `updatePrivacy` 仍需扩展 `user_update_events` privacy payload | | account.getAuthorizations | stub | empty | Settings 设备列表预取;第一阶段不展示授权设备管理,返回空 authorizations | | account.getDefaultEmojiStatuses | stub | notModified | 第一阶段不提供默认 emoji status 列表 | | account.getCollectibleEmojiStatuses | stub | empty | TDesktop 启动/emoji status 面板会刷新 collectible gift emoji statuses;当前无 gift/status 模型,返回空 `account.emojiStatuses` | @@ -119,7 +120,7 @@ status 取值:done(真实实现) / stub(兼容响应) / todo(已发现未实 | account.getGlobalPrivacySettings | stub | default | Settings 全局隐私项预取,返回空默认设置 | | account.getPasskeys | stub | empty | Settings 安全项预取,第一阶段不提供 passkeys | | account.getSavedMusicIds | stub | empty | TDesktop saved/profile music 预取;当前没有 profile music media store,返回空 id vector | -| account.updateStatus | done | persisted-presence | 记录 `auth_key/session/user` 维度运行时在线状态并持久化 `users.last_seen_at`;`offline=false` 与重连后 session 身份恢复都会写入 last_seen 并推送 `updateUserStatus(userStatusOnline expires=now+5m)`,`offline=true` 写入精确 `userStatusOffline.was_online`;最后一个 MTProto session 断开/destroy_session 也会写 last_seen 并推送 offline。状态推送给当前用户其它在线 session、在线联系人、以及已有私聊 dialog 的在线对端;session 恢复时还会向当前 session 补发在线联系人/私聊对端状态,避免 TDesktop 等用户动作后才刷新。当前未接 account privacy,已知 last_seen 默认精确可见 | +| account.updateStatus | done | persisted-presence | 记录 `auth_key/session/user` 维度运行时在线状态并持久化 `users.last_seen_at`;`offline=false` 与重连后 session 身份恢复都会写入 last_seen 并推送 `updateUserStatus(userStatusOnline expires=now+5m)`,`offline=true` 写入精确 `userStatusOffline.was_online`;最后一个 MTProto session 断开/destroy_session 也会写 last_seen 并推送 offline。状态推送给当前用户其它在线 session、在线联系人、以及已有私聊 dialog 的在线对端;session 恢复时还会向当前 session 补发在线联系人/私聊对端状态。users/dialog/history projection 已按 StatusTimestamp privacy 裁剪返回值;presence fanout 的精细 privacy 过滤仍为后续项 | ## Updates @@ -150,7 +151,7 @@ outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱 | messages.updateDialogFiltersOrder | done | real | 持久化自定义 filter 顺序,去重并忽略默认/归档保留 ID,写 durable updateDialogFilterOrder + dispatch_outbox | | messages.toggleDialogFilterTags | done | real | 持久化 folder tags 开关,写 durable updateDialogFilters reload + dispatch_outbox | | folders.editPeerFolders | done | real | 支持 folder_id=0/1 的归档/还原;user peer 更新 dialogs.folder_id,channel peer 更新 channel_dialogs.folder_id;返回/记录 updateFolderPeers(pts,pts_count=1),可靠投递其它 session | -| 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.getDialogs | done | real | 从按 user_id HASH 分区的 dialogs/message_boxes/users 查询当前账号私聊会话,并合并 channel_dialogs/channels/channel_messages;返回 users 统一走 viewer user projection(联系人名/手机号可见性 + 当前 `profile_photos` 头像),避免 TDesktop dialog 列表退回字母头像或手机号标题;支持 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;当前 owner 已保存对方但双方未 mutual 时显示 shareContact;acceptContact/add/import 形成 mutual 后清除 shareContact;若该 owner 已 hide peer settings bar 则返回空 action bar;附带当前 owner 视角用户资料 | @@ -349,9 +350,9 @@ outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱 | method | status | behavior | note | |---|---|---|---| -| contacts.getContacts | done | real | 从 contacts 表查询当前账号通讯录;支持 hash notModified;返回当前 owner 保存的姓名/电话/备注视角,互相关系按双方是否互存维护 | +| contacts.getContacts | done | real | 从 contacts 表查询当前账号通讯录;支持 hash notModified;返回当前 owner 保存的姓名/电话/备注视角,并经统一 user projection 补当前头像/状态;互相关系按双方是否互存维护 | | contacts.getContactIDs | done | real | 返回当前 owner 通讯录 user_id 列表,支持 hash notModified | -| contacts.getStatuses | done | persisted-presence | 返回当前通讯录联系人 `contactStatus`;状态来自运行时 presence、活跃 session 兜底与持久化 `users.last_seen_at`,离线联系人返回精确 `userStatusOffline.was_online`,未知用户退回 `userStatusRecently`。当前未接 account privacy,粗粒度 `recently/lastWeek/lastMonth/empty` 隐私降级仍为后续项 | +| contacts.getStatuses | done | persisted-presence | 返回当前通讯录联系人 `contactStatus`;状态来自运行时 presence、活跃 session 兜底与持久化 `users.last_seen_at`,离线联系人返回精确 `userStatusOffline.was_online`,未知用户退回 `userStatusRecently`。users/dialog/history projection 已裁剪 StatusTimestamp,`contacts.getStatuses` 的逐项 privacy 降级仍为后续项 | | 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 | @@ -359,10 +360,10 @@ outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱 | 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 索引 | | contacts.resolveUsername | done | real | 按大小写不敏感 username 解析 user 或公开 channel/supergroup peer;channel 返回 PeerChannel + Chats;不存在返回 USERNAME_NOT_OCCUPIED,非法格式返回 USERNAME_INVALID | -| contacts.resolvePhone | done | real-partial | 按手机号解析 user peer;当前阶段未接完整 privacy,默认已知手机号可解析,未命中返回 PHONE_NOT_OCCUPIED | +| contacts.resolvePhone | done | real-partial | 按手机号解析 user peer;PhoneNumber privacy 已用于资料投影与 ShareContact 例外,手机号反查入口仍按已知手机号解析,未命中返回 PHONE_NOT_OCCUPIED;按 AddedByPhone/PhoneNumber privacy 限制反查为后续项 | | contacts.block | done | real-blocklist | 写入当前 owner blocklist,幂等;同步刷新 peer settings,story-only block flag 当前按主 blocklist 处理,完整 stories privacy 留后续 | | contacts.unblock | done | real-blocklist | 从当前 owner blocklist 删除 peer,幂等;同步刷新 peer settings | -| contacts.getBlocked | done | real-blocklist | Settings 隐私/安全预取;`contacts.block/unblock/getBlocked` 维护 `owner_user_id + blocked_user_id` 唯一 blocklist,limit cap=100,按 date/user_id 返回 `peerBlocked` + users;`contacts.getPeerSettings` 按当前 owner block 状态返回 block/unblock action。当前不扩展完整 Telegram privacy key 体系,blocklist 是本阶段 send/edit/delete 的唯一 privacy gate | +| contacts.getBlocked | done | real-blocklist | Settings 隐私/安全预取;`contacts.block/unblock/getBlocked` 维护 `owner_user_id + blocked_user_id` 唯一 blocklist,limit cap=100,按 date/user_id 返回 `peerBlocked` + users;`contacts.getPeerSettings` 按当前 owner block 状态返回 block/unblock action,并按 PhoneNumber privacy 决定 `ShareContact`。blocklist 仍是私聊 send/edit/delete 的拒绝 gate,完整 privacy key 用于资料投影/手机号分享 | | contacts.getTopPeers | stub | disabled | 第一阶段不维护 top peers 统计 | | contacts.getSponsoredPeers | stub | empty | 第一阶段不做 sponsored peers,TDesktop 搜索框分支返回 sponsoredPeersEmpty | | users.getUsers | done | real | InputUserSelf 与已知 InputUser 返回用户(含 777000 官方账号);未登录则跳过(空列表) | @@ -400,12 +401,13 @@ outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱 | upload.saveBigFilePart | done | real-localfs | 累积 big file 分片,校验 total_parts 上限;组装见 uploadMedia/uploadProfilePhoto | | upload.getFile | done | real-localfs | 把 `inputDocumentFileLocation/inputPhotoFileLocation/inputPeerPhotoFileLocation` 推导为 `file_blobs.location_key`(document 查 `doc:[:type]`,photo 查 `photo::`),从本地 blob backend 按 offset/limit 切片返回 `upload.file`,storage type 优先按 bytes 魔数、再按 mime 判定,避免历史 seed 把 WebP thumb 误标 `image/jpeg` 后客户端解码失败;`location_key→FileBlob` 元数据 LRU 消除每 chunk PG 查,≤256KB sticker/reaction/thumb 小 blob 走 `object_key→bytes` LRU 并由启动 `WarmCaches` 预热;CDN/legacy `inputFileLocation`/`inputStickerSetThumb` 返回 `LOCATION_INVALID`(todo);location 仅按 `id` 解析,**不校验 `access_hash`/`file_reference`**(安全取舍见下方 note) | | upload.getFileHashes | stub | empty | 本阶段不做 CDN/分片完整性校验,返回空 hash 列表(客户端信任数据) | -| photos.uploadProfilePhoto | done | real-photo | 组装上传分片→建头像 Photo(合成 a/c 尺寸,落 `photos`/`file_blobs`/`profile_photos`),设为当前头像,返回 `photos.photo{photo, 带头像 self}` 并向其它在线 session 推 `updateUser`+self(头像即时同步,见下方 note);仅支持 file 变体,fallback/video/emoji-markup 返回 `PHOTO_INVALID` | -| photos.updateProfilePhoto | done | real-photo | `inputPhoto` 把历史头像设为当前;`inputPhotoEmpty` 停用当前头像;变更后向其它在线 session 推 `updateUser`+self 同步头像 | -| photos.getUserPhotos | done | real-photo | 按 `profile_photos` 返回某用户头像历史(最新在前),offset/limit/max_id 有界,返回 `photos.photos[Slice]` + target user | -| photos.deletePhotos | done | real-photo | 按 inputPhoto id 停用头像,返回被删 id 列表 | +| photos.uploadProfilePhoto | done | real-photo | 组装上传分片→建头像 Photo(合成 a/c 尺寸,落 `photos`/`file_blobs`/`profile_photos(kind=profile|fallback)`),`fallback=true` 设为 fallback 当前头像,否则设为真实 profile 当前头像;返回 `photos.photo{photo, 带头像 self}` 并向其它在线 session 推 `updateUser`+self;video/emoji-markup 返回 `PHOTO_INVALID` | +| photos.updateProfilePhoto | done | real-photo | `inputPhoto` 把历史 photo 设为当前 profile/fallback;`inputPhotoEmpty` 停用对应 kind 当前头像;变更后向其它在线 session 推 `updateUser`+self 同步头像 | +| photos.uploadContactProfilePhoto | todo | personal-real/suggest-pending | `save=true + file` 设置当前 viewer 对目标 contact 的 personal photo,后续 `userProfilePhoto.personal=true`;`save=true + no file` 清除 personal photo;`suggest=true` 当前只创建/返回 photo,私聊 `messageActionSuggestProfilePhoto` 仍待 private service-message action 模型 | +| photos.getUserPhotos | done | real-photo | 只按真实 `profile_photos(kind=profile)` 返回某用户头像历史(最新在前),不混入 fallback/personal;offset/limit/max_id 有界,返回 `photos.photos[Slice]` + target user | +| photos.deletePhotos | done | real-photo | 只停用真实 profile kind 的 inputPhoto id,返回被删 id 列表;fallback/personal 不受影响 | -> 头像渲染:`users` 表反范式 `photo_id/photo_dc_id/photo_stripped`,users 服务对 getUsers/getFullUser/self/resolve 批量富化,`tgUser`/`tgSelfUser` 输出 `userProfilePhoto`;channel 头像反范式 `channels.photo_*`,`tgChannel.Photo`/`tgChannelFull.ChatPhoto` 渲染。资源 seed 来自 `TELESRV_STICKER_SEED_DIR` 真实导出,启动时幂等导入(reactions/default 系统集/常规集)。 +> 头像渲染:用户头像统一来自 `internal/app/userprojection`,顺序为 viewer personal photo > ProfilePhoto privacy 允许的真实 profile > fallback > 空头像;同时叠加联系人备注/本地手机号、PhoneNumber/StatusTimestamp privacy 裁剪,`tgUser` 输出 `userProfilePhoto.personal`。`users.getFullUser` 额外回填 `personal_photo/profile_photo/fallback_photo` 与 About privacy。channel 头像反范式 `channels.photo_*`,`tgChannel.Photo`/`tgChannelFull.ChatPhoto` 渲染。资源 seed 来自 `TELESRV_STICKER_SEED_DIR` 真实导出,启动时幂等导入(reactions/default 系统集/常规集)。 > 头像多设备同步:`uploadProfilePhoto`/`updateProfilePhoto`(含 `inputPhotoEmpty` 清除)变更后复用 `pushUserUpdates`,向该账号其它在线 session 推 `updateUser` + `Updates.users` 携带含新 `userProfilePhoto` 的 self;当前设备经 RPC 返回更新,TDesktop 经 `processUser→setPhoto→peerUpdated(Photo)` 即时刷新(与 参考实现 `MakeUpdatesByUpdatesUsers([self],updateUser)` 对齐;`updateUserName` 不含 photo 无法刷新头像)。联系人/对话方不主动广播头像(Telegram 同行为,对端下次拉取 user 时刷新);频道头像变更经 `channels.editPhoto` → `pushChannelStateToMembers` 推在线成员。 diff --git a/docs/message-module.md b/docs/message-module.md index 93dfebcd..b5cb7b03 100644 --- a/docs/message-module.md +++ b/docs/message-module.md @@ -122,6 +122,7 @@ Redis miss 时分别从 `MAX(user_update_events.pts)` 与 `MAX(message_boxes.box - `messages.getHistory` 以 `owner_user_id + peer_user_id + box_id/date` 命中 `message_boxes` 分区索引;`offset_id`、`offset_date`、`max_id`、`min_id` 都下推为游标条件;`add_offset` 只允许 `[-100,100]` 的小窗口偏移,避免异常客户端把超大偏移变成 SQL 跳过扫描或内存 slice capacity。 - `messages.search` / `messages.searchGlobal` 当前只覆盖当前 owner 私聊文本搜索;查询仍限定在 `owner_user_id` HASH 分区内,文本条件由 `pg_trgm` GIN 索引兜底。参考实现 的经验,未接外部全文搜索前禁止无索引大表模糊扫;后续群组/频道/全局多 peer 搜索应接专用搜索索引或 FTS。 - `messages.getDialogs` 以 `user_id` 分区定位当前账号,再按 `top_message_date/top_message_id/peer_id` 做 seek pagination;folder_id=0/1 直接走 `dialogs.folder_id`,folder_id>=2 先取当前账号 `dialog_filters` 后按 include/exclude/contact 规则过滤;`hash` 与 `count` 基于当前筛选后的完整会话集计算。 +- `messages.getDialogs/getPeerDialogs/getHistory/getMessages/search` 返回的 users 统一经过 `internal/app/userprojection`:批量读取 viewer personal photo、`profile_photos(kind=profile|fallback)`、当前 viewer contacts 与 account privacy,按 personal > allowed profile > fallback > empty 选择头像,并按联系人备注/PhoneNumber/StatusTimestamp privacy 裁剪姓名、手机号与状态。该路径是 TDesktop dialog 标题、头像、history 标题与 action bar 用户状态的公共入口,禁止在各 handler 内重复拼用户资料。 - `messages.getDialogFilters` 返回 `dialogFilterDefault` + 当前账号持久化 filters;`messages.updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTags` 与 `folders.editPeerFolders` 都是 owner 视角写入,归档只允许 folder_id 0/1,自定义 filters 从 ID 2 开始。 - `updates.getDifference` 只按 `user_id + pts` 顺序扫描 `user_update_events`,多设备各自的 `(auth_key_id,user_id)` state 只记录消费位置,不参与账号事件归属;离线设备通过同一条 durable log 恢复新消息、已读 inbox/outbox、内容已读、文本编辑、删除消息、联系人 reset、dialog pinned/order/manual unread、peer settings、dialog filters/order/reload 与 folder peers 变化,置顶顺序、peer settings flags、filter payload 与 folder peers 会随事件负载持久化。 diff --git a/docs/persistence-layer.md b/docs/persistence-layer.md index de7dfbc6..de866809 100644 --- a/docs/persistence-layer.md +++ b/docs/persistence-layer.md @@ -60,7 +60,8 @@ DDL 见 [`deploy/migrations/0001_init.up.sql`](../deploy/migrations/0001_init.up - **`update_states`** —— `auth_key_id + user_id` 维度的设备状态快照。账号级 pts 以 `user_update_events` 为权威,设备退出/换号只清当前 auth_key 状态,不删除账号事件。 - **`user_update_events`** —— 按 `user_id` HASH 分区的账号级增量事件队列。承载 `new_message`、`read_history_inbox/read_history_outbox`(私聊与 channel peer)、`edit_message`、`read_message_contents`、`delete_messages`、`contacts_reset`、dialog 置顶/顺序/manual unread/peer settings、dialog filter/order/reload、folder peers、channel 本地清空后的 `channel_available_messages`、当前账号 forum 展示模式 `channel_view_forum_as_messages` 与 allocator gap 的 `noop`,供 `updates.getDifference` 和 outbox worker 补偿错过的推送;设置类事件通过 `UpdateEventStore.AppendWithDispatch` 与 `dispatch_outbox` 同事务写入,并持久化 `event_peers` / `peer_settings` / `message_ids` / `dialog_filter` / `filter_order` / `folder_peers` 负载,避免状态只存在在线 push 中。account difference 中的 `updateChannelTooLong` channel nudge 是按 channel events + request date 计算出来的提示,不写入本表、不消耗账号 pts。 - **`contacts`** —— 当前账号通讯录关系与 owner 视角联系人资料。`contact_phone/contact_first_name/contact_last_name/note/note_entities` 均只属于 `(user_id, contact_user_id)`,同一个全局 user 在不同 owner 的通讯录中可以有不同姓名、电话和备注;`mutual` 由双方是否互存维护,删除一方联系人会清理对方 reverse mutual。 -- **`contact_blocks`** —— 当前账号 blocklist,按 `owner_user_id` HASH 分区;`(owner_user_id, blocked_user_id)` 唯一,按 `date DESC, blocked_user_id DESC` 返回 `contacts.getBlocked`。当前 full privacy keys 未接入,私聊 send/edit/delete 的拒绝来源仅为这张表。 +- **`contact_blocks`** —— 当前账号 blocklist,按 `owner_user_id` HASH 分区;`(owner_user_id, blocked_user_id)` 唯一,按 `date DESC, blocked_user_id DESC` 返回 `contacts.getBlocked`。私聊 send/edit/delete 的拒绝来源仍是这张表;完整 account privacy keys 已由 `account_privacy_rules` 承载,用于资料投影与手机号分享例外。 +- **`account_privacy_rules`** —— 当前账号 privacy key 规则,`(owner_user_id, privacy_key)` 唯一,`rules` 为 domain PrivacyRule JSONB。无记录时由 app/privacy 计算默认规则:PhoneNumber=DisallowAll、Birthday=AllowContacts、其它=AllowAll;Premium/Bot/Stars/SavedMusic 等产品只保存/返回规则,不扩展商业模型。 - **`private_messages`** —— 共享私聊消息主体,按 `sender_user_id` HASH 分区;`sender_user_id + random_id` 唯一保证 `messages.sendMessage/forwardMessages` 幂等;文本编辑更新共享 body/entities/edit_date;silent/noforwards/reply_to/fwd_from 元数据随消息持久化。 - **`message_boxes`** —— owner 视角消息盒,按 `owner_user_id` HASH 分区;每个账号看到自己的 `box_id`、peer、outgoing、pts、edit_date、`media_unread/reaction_unread` 与删除状态,历史/搜索走该表索引。删除只软删 owner 视角 message_box,`revoke` 通过 `(message_sender_id, private_message_id)` 定位其它 owner 视角并软删;编辑会同步所有可见 owner 视角盒子。`messages.readMessageContents` 只锁定当前 owner exact ids 且仅清理 unread 状态为 true 的行,有变化才写 `read_message_contents` durable event。该反向定位与分区键不一致,规划会展开全部 owner 分区,后续应增加 unpartitioned box 映射或先推导 owner_user_id 后再按 owner 分区点查。 - **`dialogs`** —— 当前账号会话摘要,按 `user_id` HASH 分区;只允许 `user` peer,支持 top message、置顶过滤、folder_id=0/1 主列表/归档与 offset 分页,并保存当前 owner 的 `pinned_order`、manual `unread_mark` 与 `hidden_peer_settings_bar`。列表查询 join `contacts` 时优先返回当前 owner 保存的联系人姓名/电话,避免不同账号看同一 peer 串备注;相关状态变化会写入账号级 durable update log,离线设备可通过 `updates.getDifference` 恢复。 @@ -146,7 +147,7 @@ internal/store/ - **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 和归档还原。—— ✅ - **P4.5 TDesktop 搜索入口**:`contacts.search` 已支持联系人/非联系人用户搜索,`contacts.getSponsoredPeers` 返回空 sponsored peers,`messages.searchGlobal` 接当前 owner 私聊文本搜索;查询保护与索引设计参考实现,避免客户端搜索框卡在 Loading。—— ✅ -- **P4.6 内容已读与 blocklist privacy gate**:`message_boxes.media_unread/reaction_unread`、`user_update_events(read_message_contents)`、`contact_blocks` 已落地;`messages.readMessageContents` 只在实际清理 unread 内容状态时分配 pts 并 durable 推送,被 block 后私聊 send 只写 sender outbox,edit/revoke delete 会返回 forbidden。—— ✅ +- **P4.6 内容已读、blocklist gate 与 account privacy**:`message_boxes.media_unread/reaction_unread`、`user_update_events(read_message_contents)`、`contact_blocks`、`account_privacy_rules` 已落地;`messages.readMessageContents` 只在实际清理 unread 内容状态时分配 pts 并 durable 推送,被 block 后私聊 send 只写 sender outbox,edit/revoke delete 会返回 forbidden;account privacy 用于用户资料/头像/手机号分享投影。—— ✅ ## 7. 与铁律的关系 @@ -164,9 +165,10 @@ internal/store/ - `documents.id` 持久化为 telesrv-owned 正数 id;外部导出资源的 source id 只在 seed 扫描文件名/JSON 时使用,进入 `documents` / `file_blobs` / `sticker_sets` / `available_reactions` 前已归一为服务端 id。RPC、`InputDocument` 与 `inputDocumentFileLocation` 均直接使用该 id。 - `sticker_sets`:贴纸 / 自定义 emoji 集(含有序 `document_ids`、`packs`、`system_key` 路由系统集)。 - `available_reactions`:reaction 目录(引用真实文档 id)。 -- `profile_photos`(owner_peer_type+id+photo_id):用户/频道头像历史,current=active 中 sort_order 最大者。 +- `profile_photos`(owner_peer_type+id+kind+photo_id):用户/频道头像历史,`kind=profile|fallback`,current=active 中 sort_order 最大者;`photos.getUserPhotos/deletePhotos` 只操作 profile kind。 +- `contacts.personal_photo_id/personal_photo_date`:viewer 对某个 contact 的私有头像引用;`userprojection` 优先于 profile/fallback 使用,并输出 `userProfilePhoto.personal=true`。 - 消息表(`private_messages`/`message_boxes`/`channel_messages`)新增 `media` JSONB 快照列;放宽 body 非空 CHECK 为「body 非空 OR media 非空(OR channel action)」,支持「仅媒体」消息。 -- 头像反范式:`users.photo_*`(migration 0057 用户表无需改,富化在 users 服务)与 `channels.photo_*`(migration `0059`)。 +- 头像投影:用户头像当前值从 `contacts.personal_photo_id` 与 `profile_photos(kind=profile|fallback)` 批量查询,在 app 层 `userprojection` 按 viewer privacy 富化到 `domain.User.PhotoID/PhotoDCID/PhotoStripped/PhotoPersonal`;channel 头像仍反范式在 `channels.photo_*`(migration `0059`)。 - `app_configs` 增 `reactions_default` 等(migration `0058`)。 blob backend:`internal/app/files`(`BlobBackend` 接口 + `LocalFS` 本地磁盘实现,内容寻址 sha256 两级 fanout 去重,默认 `data/blobs`)。`MediaStore`(PG)只管元数据 + `file_blobs` 索引;字节由 backend 按 `object_key` 读写。 diff --git a/internal/rpc/account.go b/internal/rpc/account.go index 3b2937b2..736be9de 100644 --- a/internal/rpc/account.go +++ b/internal/rpc/account.go @@ -35,9 +35,8 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) { d.OnAccountUpdateNotifySettings(func(ctx context.Context, req *tg.AccountUpdateNotifySettingsRequest) (bool, error) { return true, nil }) - d.OnAccountGetPrivacy(func(ctx context.Context, key tg.InputPrivacyKeyClass) (*tg.AccountPrivacyRules, error) { - return tdesktop.PrivacyRules(key), nil - }) + d.OnAccountGetPrivacy(r.onAccountGetPrivacy) + d.OnAccountSetPrivacy(r.onAccountSetPrivacy) d.OnAccountGetAuthorizations(func(ctx context.Context) (*tg.AccountAuthorizations, error) { return tdesktop.Authorizations(), nil }) @@ -80,6 +79,60 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) { d.OnAccountUpdateStatus(r.onAccountUpdateStatus) } +func (r *Router) onAccountGetPrivacy(ctx context.Context, key tg.InputPrivacyKeyClass) (*tg.AccountPrivacyRules, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + domainKey, ok := domainPrivacyKeyFromInput(key) + if !ok { + return nil, privacyKeyInvalidErr() + } + if r.deps.Privacy == nil { + return tdesktop.PrivacyRules(key), nil + } + rules, err := r.deps.Privacy.GetRules(ctx, userID, domainKey) + if err != nil { + return nil, privacyErr(err) + } + return r.tgAccountPrivacyRules(ctx, userID, rules) +} + +func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPrivacyRequest) (*tg.AccountPrivacyRules, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + domainKey, ok := domainPrivacyKeyFromInput(req.Key) + if !ok { + return nil, privacyKeyInvalidErr() + } + rules, err := r.domainPrivacyRulesFromInput(ctx, userID, req.Rules) + if err != nil { + return nil, err + } + if r.deps.Privacy == nil { + return &tg.AccountPrivacyRules{Rules: tgPrivacyRules(rules), Users: []tg.UserClass{}, Chats: []tg.ChatClass{}}, nil + } + saved, err := r.deps.Privacy.SetRules(ctx, userID, domainKey, rules) + if err != nil { + return nil, privacyErr(err) + } + out, err := r.tgAccountPrivacyRules(ctx, userID, saved) + if err != nil { + return nil, err + } + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdatePrivacy{ + Key: tgPrivacyKey(saved.Key), + Rules: tgPrivacyRules(saved.Rules), + }}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + }) + return out, nil +} + func (r *Router) onAccountGetAccountTTL(ctx context.Context) (*tg.AccountDaysTTL, error) { if _, _, err := r.currentUserID(ctx); err != nil { return nil, internalErr() @@ -100,6 +153,218 @@ func (r *Router) onAccountUpdateStatus(ctx context.Context, offline bool) (bool, return true, nil } +func (r *Router) tgAccountPrivacyRules(ctx context.Context, viewerUserID int64, rules domain.PrivacyRules) (*tg.AccountPrivacyRules, error) { + userIDs := privacyRuleUserIDs(rules.Rules) + users := []domain.User{} + if r.deps.Users != nil && len(userIDs) > 0 { + var err error + users, err = r.deps.Users.ByIDs(ctx, viewerUserID, userIDs) + if err != nil { + return nil, internalErr() + } + } + return &tg.AccountPrivacyRules{ + Rules: tgPrivacyRules(rules.Rules), + Users: tgUsers(users), + Chats: []tg.ChatClass{}, + }, nil +} + +func (r *Router) domainPrivacyRulesFromInput(ctx context.Context, userID int64, in []tg.InputPrivacyRuleClass) ([]domain.PrivacyRule, error) { + out := make([]domain.PrivacyRule, 0, len(in)) + for _, rule := range in { + switch v := rule.(type) { + case *tg.InputPrivacyValueAllowContacts: + out = append(out, domain.PrivacyRule{Kind: domain.PrivacyRuleAllowContacts}) + case *tg.InputPrivacyValueAllowAll: + out = append(out, domain.PrivacyRule{Kind: domain.PrivacyRuleAllowAll}) + case *tg.InputPrivacyValueAllowUsers: + ids, err := r.privacyUserIDsFromInput(ctx, userID, v.Users) + if err != nil { + return nil, err + } + out = append(out, domain.PrivacyRule{Kind: domain.PrivacyRuleAllowUsers, UserIDs: ids}) + case *tg.InputPrivacyValueDisallowContacts: + out = append(out, domain.PrivacyRule{Kind: domain.PrivacyRuleDisallowContacts}) + case *tg.InputPrivacyValueDisallowAll: + out = append(out, domain.PrivacyRule{Kind: domain.PrivacyRuleDisallowAll}) + case *tg.InputPrivacyValueDisallowUsers: + ids, err := r.privacyUserIDsFromInput(ctx, userID, v.Users) + if err != nil { + return nil, err + } + out = append(out, domain.PrivacyRule{Kind: domain.PrivacyRuleDisallowUsers, UserIDs: ids}) + case *tg.InputPrivacyValueAllowChatParticipants: + out = append(out, domain.PrivacyRule{Kind: domain.PrivacyRuleAllowChatParticipants, ChatIDs: append([]int64(nil), v.Chats...)}) + case *tg.InputPrivacyValueDisallowChatParticipants: + out = append(out, domain.PrivacyRule{Kind: domain.PrivacyRuleDisallowChatParticipants, ChatIDs: append([]int64(nil), v.Chats...)}) + case *tg.InputPrivacyValueAllowCloseFriends: + out = append(out, domain.PrivacyRule{Kind: domain.PrivacyRuleAllowCloseFriends}) + case *tg.InputPrivacyValueAllowPremium: + out = append(out, domain.PrivacyRule{Kind: domain.PrivacyRuleAllowPremium}) + case *tg.InputPrivacyValueAllowBots: + out = append(out, domain.PrivacyRule{Kind: domain.PrivacyRuleAllowBots}) + case *tg.InputPrivacyValueDisallowBots: + out = append(out, domain.PrivacyRule{Kind: domain.PrivacyRuleDisallowBots}) + default: + return nil, privacyValueInvalidErr() + } + } + return out, nil +} + +func (r *Router) privacyUserIDsFromInput(ctx context.Context, currentUserID int64, inputs []tg.InputUserClass) ([]int64, error) { + out := make([]int64, 0, len(inputs)) + seen := make(map[int64]struct{}, len(inputs)) + for _, input := range inputs { + u, found, err := r.userFromInput(ctx, currentUserID, input) + if err != nil { + return nil, internalErr() + } + if !found || u.ID == 0 { + return nil, userIDInvalidErr() + } + if _, ok := seen[u.ID]; ok { + continue + } + seen[u.ID] = struct{}{} + out = append(out, u.ID) + } + return out, nil +} + +func domainPrivacyKeyFromInput(key tg.InputPrivacyKeyClass) (domain.PrivacyKey, bool) { + switch key.(type) { + case *tg.InputPrivacyKeyStatusTimestamp: + return domain.PrivacyKeyStatusTimestamp, true + case *tg.InputPrivacyKeyChatInvite: + return domain.PrivacyKeyChatInvite, true + case *tg.InputPrivacyKeyPhoneCall: + return domain.PrivacyKeyPhoneCall, true + case *tg.InputPrivacyKeyPhoneP2P: + return domain.PrivacyKeyPhoneP2P, true + case *tg.InputPrivacyKeyForwards: + return domain.PrivacyKeyForwards, true + case *tg.InputPrivacyKeyProfilePhoto: + return domain.PrivacyKeyProfilePhoto, true + case *tg.InputPrivacyKeyPhoneNumber: + return domain.PrivacyKeyPhoneNumber, true + case *tg.InputPrivacyKeyAddedByPhone: + return domain.PrivacyKeyAddedByPhone, true + case *tg.InputPrivacyKeyVoiceMessages: + return domain.PrivacyKeyVoiceMessages, true + case *tg.InputPrivacyKeyAbout: + return domain.PrivacyKeyAbout, true + case *tg.InputPrivacyKeyBirthday: + return domain.PrivacyKeyBirthday, true + case *tg.InputPrivacyKeyStarGiftsAutoSave: + return domain.PrivacyKeyStarGiftsAutoSave, true + case *tg.InputPrivacyKeyNoPaidMessages: + return domain.PrivacyKeyNoPaidMessages, true + case *tg.InputPrivacyKeySavedMusic: + return domain.PrivacyKeySavedMusic, true + default: + return "", false + } +} + +func tgPrivacyKey(key domain.PrivacyKey) tg.PrivacyKeyClass { + switch key { + case domain.PrivacyKeyStatusTimestamp: + return &tg.PrivacyKeyStatusTimestamp{} + case domain.PrivacyKeyChatInvite: + return &tg.PrivacyKeyChatInvite{} + case domain.PrivacyKeyPhoneCall: + return &tg.PrivacyKeyPhoneCall{} + case domain.PrivacyKeyPhoneP2P: + return &tg.PrivacyKeyPhoneP2P{} + case domain.PrivacyKeyForwards: + return &tg.PrivacyKeyForwards{} + case domain.PrivacyKeyProfilePhoto: + return &tg.PrivacyKeyProfilePhoto{} + case domain.PrivacyKeyPhoneNumber: + return &tg.PrivacyKeyPhoneNumber{} + case domain.PrivacyKeyAddedByPhone: + return &tg.PrivacyKeyAddedByPhone{} + case domain.PrivacyKeyVoiceMessages: + return &tg.PrivacyKeyVoiceMessages{} + case domain.PrivacyKeyAbout: + return &tg.PrivacyKeyAbout{} + case domain.PrivacyKeyBirthday: + return &tg.PrivacyKeyBirthday{} + case domain.PrivacyKeyStarGiftsAutoSave: + return &tg.PrivacyKeyStarGiftsAutoSave{} + case domain.PrivacyKeyNoPaidMessages: + return &tg.PrivacyKeyNoPaidMessages{} + case domain.PrivacyKeySavedMusic: + return &tg.PrivacyKeySavedMusic{} + default: + return &tg.PrivacyKeyStatusTimestamp{} + } +} + +func tgPrivacyRules(rules []domain.PrivacyRule) []tg.PrivacyRuleClass { + out := make([]tg.PrivacyRuleClass, 0, len(rules)) + for _, rule := range rules { + switch rule.Kind { + case domain.PrivacyRuleAllowContacts: + out = append(out, &tg.PrivacyValueAllowContacts{}) + case domain.PrivacyRuleAllowAll: + out = append(out, &tg.PrivacyValueAllowAll{}) + case domain.PrivacyRuleAllowUsers: + out = append(out, &tg.PrivacyValueAllowUsers{Users: append([]int64(nil), rule.UserIDs...)}) + case domain.PrivacyRuleDisallowContacts: + out = append(out, &tg.PrivacyValueDisallowContacts{}) + case domain.PrivacyRuleDisallowAll: + out = append(out, &tg.PrivacyValueDisallowAll{}) + case domain.PrivacyRuleDisallowUsers: + out = append(out, &tg.PrivacyValueDisallowUsers{Users: append([]int64(nil), rule.UserIDs...)}) + case domain.PrivacyRuleAllowChatParticipants: + out = append(out, &tg.PrivacyValueAllowChatParticipants{Chats: append([]int64(nil), rule.ChatIDs...)}) + case domain.PrivacyRuleDisallowChatParticipants: + out = append(out, &tg.PrivacyValueDisallowChatParticipants{Chats: append([]int64(nil), rule.ChatIDs...)}) + case domain.PrivacyRuleAllowCloseFriends: + out = append(out, &tg.PrivacyValueAllowCloseFriends{}) + case domain.PrivacyRuleAllowPremium: + out = append(out, &tg.PrivacyValueAllowPremium{}) + case domain.PrivacyRuleAllowBots: + out = append(out, &tg.PrivacyValueAllowBots{}) + case domain.PrivacyRuleDisallowBots: + out = append(out, &tg.PrivacyValueDisallowBots{}) + } + } + return out +} + +func privacyRuleUserIDs(rules []domain.PrivacyRule) []int64 { + seen := map[int64]struct{}{} + out := make([]int64, 0) + for _, rule := range rules { + for _, id := range rule.UserIDs { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + } + return out +} + +func privacyErr(err error) error { + switch { + case errors.Is(err, domain.ErrPrivacyKeyInvalid): + return privacyKeyInvalidErr() + case errors.Is(err, domain.ErrPrivacyRuleInvalid): + return privacyValueInvalidErr() + default: + return internalErr() + } +} + type accountReactionSettingsService interface { GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, error) SetReactionsNotifySettings(ctx context.Context, userID int64, settings domain.ReactionsNotifySettings) (domain.AccountReactionSettings, error) diff --git a/internal/rpc/convert.go b/internal/rpc/convert.go index e339632f..1dec9d01 100644 --- a/internal/rpc/convert.go +++ b/internal/rpc/convert.go @@ -112,7 +112,7 @@ func tgUserProfilePhoto(u domain.User) tg.UserProfilePhotoClass { if u.PhotoID == 0 { return nil } - photo := &tg.UserProfilePhoto{PhotoID: u.PhotoID, DCID: u.PhotoDCID} + photo := &tg.UserProfilePhoto{PhotoID: u.PhotoID, DCID: u.PhotoDCID, Personal: u.PhotoPersonal} if len(u.PhotoStripped) > 0 { photo.SetStrippedThumb(u.PhotoStripped) } diff --git a/internal/rpc/convert_test.go b/internal/rpc/convert_test.go index f6e41b60..38f5b8da 100644 --- a/internal/rpc/convert_test.go +++ b/internal/rpc/convert_test.go @@ -29,3 +29,35 @@ func TestTGMessagesMessagesMarksViewerSelfAndKeepsProjectedPhone(t *testing.T) { t.Fatalf("peer user = %+v ok=%v, want projected non-self without phone", full.Users[1], ok) } } + +func TestTGMessagesDialogsIncludesUserProfilePhoto(t *testing.T) { + const viewerID int64 = 1001 + const peerID int64 = 1002 + res := tgMessagesDialogs(viewerID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + TopMessage: 1, + TopMessageDate: 10, + }}, + Users: []domain.User{{ + ID: peerID, + AccessHash: 22, + FirstName: "Alice A", + PhotoID: 9301, + PhotoDCID: 2, + PhotoStripped: []byte{9, 10}, + }}, + }) + full, ok := res.(*tg.MessagesDialogs) + if !ok { + t.Fatalf("result = %T, want *tg.MessagesDialogs", res) + } + peer, ok := full.Users[0].(*tg.User) + if !ok { + t.Fatalf("user = %T, want *tg.User", full.Users[0]) + } + photo, ok := peer.Photo.(*tg.UserProfilePhoto) + if !ok || photo.PhotoID != 9301 || photo.DCID != 2 || string(photo.StrippedThumb) != string([]byte{9, 10}) { + t.Fatalf("photo = %+v ok=%v, want userProfilePhoto 9301/2/[9 10]", peer.Photo, ok) + } +} diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index 854ba958..6a1f1320 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -96,6 +96,10 @@ func pollAnswerInvalidErr() error { return tgerr.New(400, "POLL_ANSWER_INVALID") func reactionInvalidErr() error { return tgerr.New(400, "REACTION_INVALID") } +func privacyKeyInvalidErr() error { return tgerr.New(400, "PRIVACY_KEY_INVALID") } + +func privacyValueInvalidErr() error { return tgerr.New(400, "PRIVACY_VALUE_INVALID") } + func todoItemsEmptyErr() error { return tgerr.New(400, "TODO_ITEMS_EMPTY") } func todoNotModifiedErr() error { return tgerr.New(400, "TODO_NOT_MODIFIED") } diff --git a/internal/rpc/photos.go b/internal/rpc/photos.go index 04c32ab1..fa0061a7 100644 --- a/internal/rpc/photos.go +++ b/internal/rpc/photos.go @@ -13,6 +13,7 @@ import ( func (r *Router) registerPhotos(d *tg.ServerDispatcher) { d.OnPhotosUploadProfilePhoto(r.onPhotosUploadProfilePhoto) d.OnPhotosUpdateProfilePhoto(r.onPhotosUpdateProfilePhoto) + d.OnPhotosUploadContactProfilePhoto(r.onPhotosUploadContactProfilePhoto) d.OnPhotosGetUserPhotos(r.onPhotosGetUserPhotos) d.OnPhotosDeletePhotos(r.onPhotosDeletePhotos) } @@ -28,16 +29,23 @@ func (r *Router) onPhotosUploadProfilePhoto(ctx context.Context, req *tg.PhotosU if !ok || userID == 0 { return nil, photoInvalidErr() } + if bot, hasBot := req.GetBot(); hasBot && bot != nil { + return nil, inputConstructorInvalidErr() + } file, hasFile := req.GetFile() if !hasFile { - // 仅 fallback / video / emoji markup 等本阶段不支持的头像变体。 + // video / emoji markup 等头像变体本阶段不支持。 return nil, photoInvalidErr() } ref, ok := uploadedFileRef(userID, file) if !ok { return nil, fileReferenceInvalidErr() } - photo, err := r.deps.Files.UploadProfilePhoto(ctx, domain.PeerTypeUser, userID, ref, int(r.clock.Now().Unix())) + kind := domain.ProfilePhotoKindProfile + if req.GetFallback() { + kind = domain.ProfilePhotoKindFallback + } + photo, err := r.deps.Files.UploadProfilePhotoKind(ctx, domain.PeerTypeUser, userID, kind, ref, int(r.clock.Now().Unix())) if err != nil { return nil, photoUploadErr(err) } @@ -55,9 +63,16 @@ func (r *Router) onPhotosUpdateProfilePhoto(ctx context.Context, req *tg.PhotosU if !ok || userID == 0 { return nil, photoInvalidErr() } + if bot, hasBot := req.GetBot(); hasBot && bot != nil { + return nil, inputConstructorInvalidErr() + } + kind := domain.ProfilePhotoKindProfile + if req.GetFallback() { + kind = domain.ProfilePhotoKindFallback + } switch in := req.ID.(type) { case *tg.InputPhoto: - photo, found, err := r.deps.Files.SetCurrentProfilePhoto(ctx, domain.PeerTypeUser, userID, in.ID, int(r.clock.Now().Unix())) + photo, found, err := r.deps.Files.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, kind, in.ID, int(r.clock.Now().Unix())) if err != nil { return nil, internalErr() } @@ -67,13 +82,60 @@ func (r *Router) onPhotosUpdateProfilePhoto(ctx context.Context, req *tg.PhotosU return r.photosPhotoForSelf(ctx, userID, photo), nil default: // InputPhotoEmpty:移除当前头像(停用现有当前照片)。 - if cur, found, err := r.deps.Files.CurrentProfilePhoto(ctx, domain.PeerTypeUser, userID); err == nil && found { - _, _ = r.deps.Files.DeleteProfilePhotos(ctx, domain.PeerTypeUser, userID, []int64{cur.ID}) + if cur, found, err := r.deps.Files.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, kind); err == nil && found { + _, _ = r.deps.Files.DeleteProfilePhotosKind(ctx, domain.PeerTypeUser, userID, kind, []int64{cur.ID}) } return r.photosPhotoForSelf(ctx, userID, domain.Photo{}), nil } } +func (r *Router) onPhotosUploadContactProfilePhoto(ctx context.Context, req *tg.PhotosUploadContactProfilePhotoRequest) (*tg.PhotosPhoto, error) { + if r.deps.Files == nil || r.deps.Contacts == nil { + return nil, notImplementedErr() + } + userID, ok, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if !ok || userID == 0 { + return nil, photoInvalidErr() + } + target, found, err := r.userFromInput(ctx, userID, req.UserID) + if err != nil { + return nil, internalErr() + } + if !found || target.ID == 0 || target.ID == userID { + return nil, userIDInvalidErr() + } + file, hasFile := req.GetFile() + if !hasFile { + if req.GetSave() { + if _, err := r.deps.Contacts.ClearPersonalPhoto(ctx, userID, target.ID, int(r.clock.Now().Unix())); err != nil { + return nil, contactErr(err) + } + return r.photosPhotoForUser(ctx, userID, target.ID, domain.Photo{}), nil + } + return nil, photoInvalidErr() + } + ref, ok := uploadedFileRef(userID, file) + if !ok { + return nil, fileReferenceInvalidErr() + } + photo, err := r.deps.Files.CreateAvatarFromUpload(ctx, ref) + if err != nil { + return nil, photoUploadErr(err) + } + if req.GetSuggest() { + // TODO: add private MessageActionSuggestProfilePhoto once private service-message + // actions exist in the domain message model. + return r.photosPhotoForUser(ctx, userID, target.ID, photo), nil + } + if _, err := r.deps.Contacts.SetPersonalPhoto(ctx, userID, target.ID, photo, int(r.clock.Now().Unix())); err != nil { + return nil, contactErr(err) + } + return r.photosPhotoForUser(ctx, userID, target.ID, photo), nil +} + func (r *Router) onPhotosGetUserPhotos(ctx context.Context, req *tg.PhotosGetUserPhotosRequest) (tg.PhotosPhotosClass, error) { if r.deps.Files == nil { return &tg.PhotosPhotos{}, nil @@ -158,6 +220,19 @@ func (r *Router) photosPhotoForSelf(ctx context.Context, userID int64, photo dom return out } +func (r *Router) photosPhotoForUser(ctx context.Context, viewerUserID, targetUserID int64, photo domain.Photo) *tg.PhotosPhoto { + out := &tg.PhotosPhoto{Photo: tgPhoto(photo), Users: []tg.UserClass{}} + if r.deps.Users == nil { + return out + } + user, found, err := r.deps.Users.ByID(ctx, viewerUserID, targetUserID) + if err != nil || !found { + return out + } + out.Users = append(out.Users, r.tgUser(user)) + return out +} + // pushSelfPhotoUpdate 向该账号其它在线设备推送头像变更。updateUserName 不含 photo 无法刷新 // 头像;updateUser 只是「该 user 变了」的信号(TDesktop 仅当 peer 已 full-loaded 时才 // forceFull 重拉);最可靠是在 Updates.Users 带上含新 userProfilePhoto 的完整 self user, diff --git a/internal/rpc/users.go b/internal/rpc/users.go index 8dbbbcc3..2b41cbb8 100644 --- a/internal/rpc/users.go +++ b/internal/rpc/users.go @@ -87,12 +87,25 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) ( if _, ok := id.(*tg.InputUserSelf); ok { user = r.tgSelfUser(u) } + about := u.About + if r.deps.Privacy != nil && u.ID != currentUserID { + allowed, err := r.deps.Privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyAbout) + if err != nil { + return nil, internalErr() + } + if !allowed { + about = "" + } + } full := tg.UserFull{ ID: u.ID, - About: u.About, + About: about, Settings: tg.PeerSettings{}, NotifySettings: *tdesktop.NotifySettings(), } + if err := r.fillUserFullPhotos(ctx, currentUserID, u.ID, &full); err != nil { + return nil, err + } if r.deps.Channels != nil && u.ID != currentUserID { common, err := r.deps.Channels.CommonChannels(ctx, currentUserID, domain.CommonChannelsRequest{ UserID: currentUserID, @@ -137,6 +150,62 @@ func (r *Router) onUsersGetSavedMusicByID(ctx context.Context, req *tg.UsersGetS }, nil } +func (r *Router) fillUserFullPhotos(ctx context.Context, viewerUserID, ownerUserID int64, full *tg.UserFull) error { + if r.deps.Files == nil || full == nil || ownerUserID == 0 { + return nil + } + if viewerUserID == ownerUserID { + if photo, found, err := r.deps.Files.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, ownerUserID, domain.ProfilePhotoKindProfile); err != nil { + return internalErr() + } else if found { + full.SetProfilePhoto(tgPhoto(photo)) + } + if photo, found, err := r.deps.Files.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, ownerUserID, domain.ProfilePhotoKindFallback); err != nil { + return internalErr() + } else if found { + full.SetFallbackPhoto(tgPhoto(photo)) + } + return nil + } + if r.deps.Contacts != nil { + refs, err := r.deps.Contacts.PersonalPhotos(ctx, viewerUserID, []int64{ownerUserID}) + if err != nil { + return internalErr() + } + if ref, ok := refs[ownerUserID]; ok && ref.PhotoID != 0 { + photo, found, err := r.deps.Files.GetPhoto(ctx, ref.PhotoID) + if err != nil { + return internalErr() + } + if found { + full.SetPersonalPhoto(tgPhoto(photo)) + } + } + } + profileAllowed := true + if r.deps.Privacy != nil { + var err error + profileAllowed, err = r.deps.Privacy.CanSee(ctx, ownerUserID, viewerUserID, domain.PrivacyKeyProfilePhoto) + if err != nil { + return internalErr() + } + } + if profileAllowed { + if photo, found, err := r.deps.Files.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, ownerUserID, domain.ProfilePhotoKindProfile); err != nil { + return internalErr() + } else if found { + full.SetProfilePhoto(tgPhoto(photo)) + } + return nil + } + if photo, found, err := r.deps.Files.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, ownerUserID, domain.ProfilePhotoKindFallback); err != nil { + return internalErr() + } else if found { + full.SetFallbackPhoto(tgPhoto(photo)) + } + return nil +} + func emptyUserFull() *tg.UsersUserFull { return &tg.UsersUserFull{ FullUser: tg.UserFull{