owpengram-server/internal/rpc/users.go

891 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package rpc
import (
"context"
"errors"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/app/users"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
const maxSavedMusicLimit = 100
const maxRequirementsToContactUsers = 100
// registerUsers 注册 users.* RPC handler。
func (r *Router) registerUsers(d *tlprofile.Dispatcher) {
registerRPC[*tg.UsersGetUsersRequest](d, tlprofile.SemanticMethodUsersGetUsers, func(ctx context.Context, layerRequest *tg.UsersGetUsersRequest) (any, error) {
return r.onUsersGetUsers(ctx, layerRequest.
ID)
})
registerRPC[*tg.UsersGetFullUserRequest](d, tlprofile.SemanticMethodUsersGetFullUser, func(ctx context.Context, layerRequest *tg.UsersGetFullUserRequest) (any, error) {
return r.onUsersGetFullUser(ctx, layerRequest.
ID)
})
registerRPC[*tg.UsersGetRequirementsToContactRequest](d, tlprofile.SemanticMethodUsersGetRequirementsToContact, func(ctx context.Context, layerRequest *tg.UsersGetRequirementsToContactRequest) (any, error) {
return r.onUsersGetRequirementsToContact(ctx, layerRequest.
ID)
})
registerRPC[*tg.UsersGetSavedMusicRequest](d, tlprofile.SemanticMethodUsersGetSavedMusic, func(ctx context.Context, layerRequest *tg.UsersGetSavedMusicRequest) (
// onUsersGetUsers 处理 users.getUsers支持 self 和已知 user peer含 777000 官方账号)。
any, error) {
return r.onUsersGetSavedMusic(ctx, layerRequest)
})
registerRPC[*tg.UsersGetSavedMusicByIDRequest](d, tlprofile.SemanticMethodUsersGetSavedMusicByID, func(ctx context.Context, layerRequest *tg.UsersGetSavedMusicByIDRequest) (any, error) {
return r.onUsersGetSavedMusicByID(ctx, layerRequest)
})
}
func (r *Router) onUsersGetUsers(ctx context.Context, ids []tg.InputUserClass) ([]tg.UserClass, error) {
currentUserID, authorized, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if r.deps.Users == nil {
return []tg.UserClass{}, nil
}
type userInput struct {
self bool
userID int64
accessHash int64
}
inputs := make([]userInput, 0, len(ids))
uniqueUserIDs := make([]int64, 0, len(ids))
seenUserIDs := make(map[int64]struct{}, len(ids))
needSelf := false
for _, in := range ids {
switch v := in.(type) {
case *tg.InputUserSelf:
if !authorized {
continue
}
needSelf = true
inputs = append(inputs, userInput{self: true})
case *tg.InputUser:
if !authorized || v.UserID == 0 {
continue
}
inputs = append(inputs, userInput{userID: v.UserID, accessHash: v.AccessHash})
if _, ok := seenUserIDs[v.UserID]; ok {
continue
}
seenUserIDs[v.UserID] = struct{}{}
uniqueUserIDs = append(uniqueUserIDs, v.UserID)
}
}
var selfUser domain.User
if needSelf {
selfUser, err = r.deps.Users.Self(ctx, currentUserID)
if err != nil {
if errors.Is(err, users.ErrNotAuthorized) {
needSelf = false
} else {
return nil, internalErr()
}
} else if selfUser.ID == 0 {
needSelf = false
}
}
usersByID := make(map[int64]domain.User, len(uniqueUserIDs))
if len(uniqueUserIDs) > 0 {
list, err := r.deps.Users.ByIDs(ctx, currentUserID, uniqueUserIDs)
if err != nil {
if !errors.Is(err, users.ErrNotAuthorized) {
return nil, internalErr()
}
}
for _, u := range list {
if u.ID != 0 {
usersByID[u.ID] = u
}
}
}
out := make([]tg.UserClass, 0, len(ids))
for _, in := range inputs {
if in.self {
if needSelf {
out = append(out, r.tgSelfUser(selfUser))
}
continue
}
u, found := usersByID[in.userID]
if !found || (in.accessHash != 0 && in.accessHash != u.AccessHash) {
continue
}
// 客户端也可能用显式自己 ID非 inputUserSelf请求同样须带 self 标志,
// 否则 self=false 的自己 user 会污染 DrKLO 账号缓存Saved Messages 变身)。
if u.ID == currentUserID {
out = append(out, r.tgSelfUser(u))
continue
}
out = append(out, r.tgUser(u))
}
r.applyPeerReadModels(ctx, currentUserID, out, nil)
return out, nil
}
func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (*tg.UsersUserFull, error) {
if r.deps.Users == nil {
return emptyUserFull(), nil
}
currentUserID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
u, found, err := r.userFromInput(ctx, currentUserID, id)
if err != nil {
if errors.Is(err, users.ErrNotAuthorized) {
return emptyUserFull(), nil
}
return nil, internalErr()
}
if !found {
return emptyUserFull(), nil
}
user := r.tgUser(u)
if _, ok := id.(*tg.InputUserSelf); ok || u.ID == currentUserID {
user = r.tgSelfUser(u)
}
if err := r.applyBotCanEditToUser(ctx, currentUserID, u, user); err != nil {
return nil, err
}
contactRestriction, err := r.privateContactRestrictionFor(ctx, currentUserID, u.ID)
if err != nil {
return nil, err
}
applyPrivateContactRestrictionToUser(user, contactRestriction)
r.applyPeerReadModels(ctx, currentUserID, []tg.UserClass{user}, nil)
loadEpoch := r.userFullProjectionCache.LoadEpoch()
if full, ok := r.userFullProjectionCache.Lookup(currentUserID, u.ID); ok {
if !applyContactNoteToUserFull(u, &full) {
return nil, internalErr()
}
if err := r.applyTranslationDisabledToUserFull(ctx, currentUserID, u.ID, &full); err != nil {
return nil, err
}
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
r.applyBotVerificationToUserFull(ctx, u.ID, &full)
applyPrivateContactRestrictionToUserFull(&full, contactRestriction)
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
return &tg.UsersUserFull{
FullUser: full,
Users: []tg.UserClass{user},
Chats: chats,
}, nil
}
full, err := r.buildUserFullProjection(ctx, currentUserID, u)
if err != nil {
return nil, err
}
r.userFullProjectionCache.StoreIfEpoch(currentUserID, u.ID, full, loadEpoch)
if !applyContactNoteToUserFull(u, &full) {
return nil, internalErr()
}
if err := r.applyTranslationDisabledToUserFull(ctx, currentUserID, u.ID, &full); err != nil {
return nil, err
}
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
// Deliberately after StoreIfEpoch, like applyPersonalChannelToUserFull: the mark
// is not baked into the per-(viewer,target) cache, so a revoked badge is gone on
// the next response instead of lingering for the cache TTL.
r.applyBotVerificationToUserFull(ctx, u.ID, &full)
applyPrivateContactRestrictionToUserFull(&full, contactRestriction)
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
return &tg.UsersUserFull{
FullUser: full,
Users: []tg.UserClass{user},
Chats: chats,
}, nil
}
// applyContactNoteToUserFull overlays the viewer-scoped contact note after the
// expensive UserFull projection cache. This keeps private notes out of the
// large LRU while reusing the contact projection already loaded by Users.ByID,
// so users.getFullUser adds neither a PostgreSQL query nor an N+1 read.
func applyContactNoteToUserFull(user domain.User, full *tg.UserFull) bool {
if full == nil {
return false
}
full.Flags2.Unset(22)
full.Note = tg.TextWithEntities{}
if !user.Contact {
return user.ContactNote == "" && len(user.ContactNoteEntities) == 0
}
if user.ContactNote == "" {
return len(user.ContactNoteEntities) == 0
}
if !utf8.ValidString(user.ContactNote) || utf8.RuneCountInString(user.ContactNote) > maxContactNoteLength ||
len(user.ContactNoteEntities) > maxMessageEntityCount || !validEphemeralEntityBounds(user.ContactNote, user.ContactNoteEntities) {
return false
}
entities := tgMessageEntities(user.ContactNoteEntities)
if len(entities) != len(user.ContactNoteEntities) {
return false
}
for _, entity := range user.ContactNoteEntities {
switch entity.Type {
case domain.MessageEntityCustomEmoji:
if entity.DocumentID <= 0 {
return false
}
case domain.MessageEntityMentionName:
if entity.UserID <= 0 {
return false
}
}
}
full.SetNote(tg.TextWithEntities{
Text: user.ContactNote,
Entities: entities,
})
return true
}
func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int64, u domain.User) (tg.UserFull, error) {
visibility, err := r.userFullPrivacyVisibility(ctx, currentUserID, u.ID)
if err != nil {
return tg.UserFull{}, internalErr()
}
about := u.About
if !visibility[domain.PrivacyKeyAbout] {
about = ""
}
full := tg.UserFull{
ID: u.ID,
About: about,
Settings: tg.PeerSettings{},
NotifySettings: *tdesktop.NotifySettings(),
}
if u.ID != currentUserID {
if svc, ok := r.deps.Messages.(PrivateNoForwardsService); ok {
state, err := svc.GetPrivateNoForwards(ctx, currentUserID, u.ID)
if err != nil {
return tg.UserFull{}, internalErr()
}
myEnabled, peerEnabled := state.ForViewer(currentUserID)
full.SetNoforwardsMyEnabled(myEnabled)
full.SetNoforwardsPeerEnabled(peerEnabled)
}
}
// 通话入口:客户端不见 phone_calls_available=true 不显示通话按钮P1 前置项)。
// phone_calls_private 标记对端禁 P2Pp2p_allowed 真值在通话确认时另行计算)。
if !u.Bot && u.ID != currentUserID {
callsAllowed := visibility[domain.PrivacyKeyPhoneCall]
p2pAllowed := visibility[domain.PrivacyKeyPhoneP2P]
voiceAllowed := visibility[domain.PrivacyKeyVoiceMessages]
full.PhoneCallsAvailable = callsAllowed
full.VideoCallsAvailable = callsAllowed
full.PhoneCallsPrivate = !p2pAllowed
full.VoiceMessagesForbidden = !voiceAllowed
}
if u.Bot {
full.SetBotInfo(r.tgBotInfo(ctx, u))
}
if r.deps.Dialogs != nil && u.ID != currentUserID {
list, err := r.deps.Dialogs.GetPeerDialogs(ctx, currentUserID, []domain.Peer{{Type: domain.PeerTypeUser, ID: u.ID}})
if err != nil {
return tg.UserFull{}, internalErr()
}
for _, dialog := range list.Dialogs {
if dialog.Peer.Type != domain.PeerTypeUser || dialog.Peer.ID != u.ID {
continue
}
if dialog.HasScheduled {
full.SetHasScheduled(true)
}
if dialog.TTLPeriod > 0 {
full.SetTTLPeriod(dialog.TTLPeriod)
}
if dialog.ThemeEmoticon != "" {
full.SetTheme(&tg.ChatTheme{Emoticon: dialog.ThemeEmoticon})
}
break
}
}
if err := r.fillUserFullPhotos(ctx, currentUserID, u.ID, visibility[domain.PrivacyKeyProfilePhoto], &full); err != nil {
return tg.UserFull{}, err
}
if r.deps.Account != nil {
if visibility[domain.PrivacyKeySavedMusic] {
music, err := r.deps.Account.ListSavedMusic(ctx, u.ID, 0, 1)
if err != nil {
return tg.UserFull{}, internalErr()
}
if len(music.Documents) > 0 {
full.SetSavedMusic(tgDocument(music.Documents[0]))
}
}
}
if svc, ok := r.accountBusinessAutomation(); ok {
profile, found, err := svc.GetBusinessProfile(ctx, u.ID)
if err != nil {
return tg.UserFull{}, internalErr()
}
if found {
r.applyBusinessProfileToUserFull(ctx, &full, profile)
}
}
if r.deps.Messages != nil {
// 置顶发现链路userFull.pinned_msg_id 是该私聊(含 Saved
// Messages当前最顶置顶客户端凭此触发 filterPinned 搜索。
pinnedList, err := r.deps.Messages.Search(ctx, currentUserID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: u.ID},
PinnedOnly: true,
Limit: 1,
})
if err != nil {
return tg.UserFull{}, internalErr()
}
if len(pinnedList.Messages) > 0 {
full.SetPinnedMsgID(pinnedList.Messages[0].ID)
}
// 私聊双方恒可置顶(官方对 user peer 置位 can_pin_message
full.SetCanPinMessage(true)
}
if r.deps.Channels != nil && u.ID != currentUserID {
common, err := r.deps.Channels.CommonChannels(ctx, currentUserID, domain.CommonChannelsRequest{
UserID: currentUserID,
TargetUserID: u.ID,
Limit: 1,
CountOnly: true,
})
if err != nil {
return tg.UserFull{}, internalErr()
}
full.CommonChatsCount = common.Count
}
// 屏蔽 premium 礼物赠送telesrv 未实现 payments.getPremiumGiftCodeOptions
// DrKLO GiftSheet 对个人送礼时总会渲染一个「Gift Premium」区段fillItems:918
// premiumTiers 永远空 → 卡死成三个 flicker 占位骨架。设 disallow_premium_gifts=true
// 会让该区段整体隐藏GiftSheet:918 gate而 star gift 礼物墙不受影响。
// 仅对「他人视角」下发:送礼给自己时该区段本就被 !self 排除(无占位),故 self 投影
// 保持干净,避免用户自己的礼物隐私设置里「接收 premium 礼物」被显示为关闭。
// 务必只设这一个 flag——四个 disallow 全置会触发 GiftSheet:788 整个送礼弹窗自动 dismiss。
if u.ID != currentUserID {
var disallow tg.DisallowedGiftsSettings
disallow.SetDisallowPremiumGifts(true)
full.SetDisallowedGifts(disallow)
}
// 生日account.updateBirthday落 userFull.birthday按 PrivacyKeyBirthday 对他人裁剪,
// 本人恒可见。
if u.Birthday.IsSet() {
if visibility[domain.PrivacyKeyBirthday] {
full.SetBirthday(tgBirthday(u.Birthday))
}
}
// 个人频道account.updatePersonalChannel不在此落地它按 viewer 实时解析,作为缓存后的
// overlay 处理applyPersonalChannelToUserFull避免烤进 per-(viewer,target) 投影缓存以及
// build/chats 两次解析同一频道。
return full, nil
}
// userFullPrivacyVisibility evaluates every privacy-controlled UserFull field
// from one owner snapshot when the service supports batching. Older test or
// alternate implementations retain scalar parity; a missing batch key fails
// closed instead of exposing that field.
func (r *Router) userFullPrivacyVisibility(ctx context.Context, viewerUserID, ownerUserID int64) (map[domain.PrivacyKey]bool, error) {
keys := []domain.PrivacyKey{
domain.PrivacyKeyAbout,
domain.PrivacyKeyPhoneCall,
domain.PrivacyKeyPhoneP2P,
domain.PrivacyKeyVoiceMessages,
domain.PrivacyKeyProfilePhoto,
domain.PrivacyKeySavedMusic,
domain.PrivacyKeyBirthday,
}
out := make(map[domain.PrivacyKey]bool, len(keys))
if r.deps.Privacy == nil || ownerUserID == viewerUserID {
for _, key := range keys {
out[key] = true
}
return out, nil
}
if batch, ok := r.deps.Privacy.(batchPrivacyEvaluator); ok {
matrix, err := batch.CanSeeBatch(ctx, []int64{ownerUserID}, viewerUserID, keys)
if err != nil {
return nil, err
}
ownerVisibility, found := matrix[ownerUserID]
if !found {
return out, nil
}
for _, key := range keys {
out[key] = ownerVisibility[key]
}
return out, nil
}
for _, key := range keys {
allowed, err := r.deps.Privacy.CanSee(ctx, ownerUserID, viewerUserID, key)
if err != nil {
return nil, err
}
out[key] = allowed
}
return out, nil
}
// tgBirthday 把 domain 生日转 tg.BirthdayYear 可选0 表示不含年份)。
func tgBirthday(b domain.Birthday) tg.Birthday {
out := tg.Birthday{Day: b.Day, Month: b.Month}
if b.Year != 0 {
out.SetYear(b.Year)
}
return out
}
// applyPersonalChannelToUserFull 一次解析资料页个人频道:设 personal_channel_id + 最新一帖 id
// 到 userFull并返回频道对象供响应 chats频道不可解析/已删则不下发。放在 userFull 投影缓存
// 之后做(与 notify/stories overlay 同构):个人频道可访问性随 viewer 变化须实时解析,且避免
// build缓存 miss与 chats 两次解析同一频道。personalChannelID 取自当次加载的 owner 用户。
func (r *Router) applyPersonalChannelToUserFull(ctx context.Context, viewerUserID, personalChannelID int64, full *tg.UserFull) []tg.ChatClass {
if personalChannelID == 0 || r.deps.Channels == nil {
return nil
}
view, err := r.deps.Channels.ResolveChannel(ctx, viewerUserID, personalChannelID)
if err != nil || view.Channel.ID == 0 || view.Channel.Deleted {
return nil
}
full.SetPersonalChannelID(view.Channel.ID)
full.SetPersonalChannelMessage(view.Channel.TopMessageID)
return []tg.ChatClass{tgChannelChatForView(viewerUserID, view)}
}
func (r *Router) applyBotCanEditToUser(ctx context.Context, currentUserID int64, u domain.User, user *tg.User) error {
if !u.Bot || r.deps.Bots == nil || currentUserID == 0 || currentUserID == u.ID || user == nil {
return nil
}
// owner 视角置 bot_can_edit(flags2.1)TDesktop/DrKLO 据此显示 bot 管理 UI
//(改名/简介/头像入口)。仅 owner 看得到。
owns, err := r.deps.Bots.OwnsBot(ctx, currentUserID, u.ID)
if err != nil {
return nil
}
if owns {
user.SetBotCanEdit(true)
}
return nil
}
func (r *Router) onUsersGetRequirementsToContact(ctx context.Context, ids []tg.InputUserClass) ([]tg.RequirementToContactClass, error) {
if len(ids) > maxRequirementsToContactUsers {
return nil, limitInvalidErr()
}
currentUserID, authorized, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !authorized || r.deps.Users == nil {
out := make([]tg.RequirementToContactClass, len(ids))
for i := range out {
out[i] = &tg.RequirementToContactEmpty{}
}
return out, nil
}
type requirementInput struct {
userID int64
accessHash int64
self bool
}
inputs := make([]requirementInput, len(ids))
targetIDs := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for i, input := range ids {
switch value := input.(type) {
case *tg.InputUserSelf:
inputs[i] = requirementInput{userID: currentUserID, self: true}
case *tg.InputUser:
if value == nil || value.UserID == 0 {
continue
}
inputs[i] = requirementInput{userID: value.UserID, accessHash: value.AccessHash}
if _, ok := seen[value.UserID]; !ok {
seen[value.UserID] = struct{}{}
targetIDs = append(targetIDs, value.UserID)
}
}
}
usersByID := make(map[int64]domain.User, len(targetIDs))
if len(targetIDs) > 0 {
list, err := r.deps.Users.ByIDs(ctx, currentUserID, targetIDs)
if err != nil && !errors.Is(err, users.ErrNotAuthorized) {
return nil, internalErr()
}
for _, user := range list {
usersByID[user.ID] = user
}
}
validTargetIDs := make([]int64, 0, len(targetIDs))
for i, input := range inputs {
if input.self {
continue
}
user, ok := usersByID[input.userID]
if !ok || input.accessHash != 0 && input.accessHash != user.AccessHash {
inputs[i].userID = 0
continue
}
validTargetIDs = append(validTargetIDs, user.ID)
}
settingsByUser := make(map[int64]domain.AccountSettings, len(validTargetIDs))
if svc, ok := r.accountSettingsSvc(); ok {
settingsByUser, err = r.accountSettings.getOrLoadBatch(ctx, validTargetIDs, svc)
if err != nil {
return nil, internalErr()
}
}
freeByUser := make(map[int64]bool, len(validTargetIDs))
if evaluator, ok := r.deps.Privacy.(contactRequirementPrivacyEvaluator); ok {
freeByUser, err = evaluator.CanContactForFreeBatch(ctx, validTargetIDs, currentUserID)
if err != nil {
return nil, internalErr()
}
}
out := make([]tg.RequirementToContactClass, len(inputs))
for i, input := range inputs {
out[i] = &tg.RequirementToContactEmpty{}
if input.userID == 0 || input.self || input.userID == currentUserID || freeByUser[input.userID] {
continue
}
global := settingsByUser[input.userID].GlobalPrivacy
switch {
case global.NoncontactPeersPaidStars > 0:
out[i] = &tg.RequirementToContactPaidMessages{StarsAmount: global.NoncontactPeersPaidStars}
case global.NewNoncontactPeersRequirePremium:
out[i] = &tg.RequirementToContactPremium{}
}
}
return out, nil
}
type contactRequirementPrivacyEvaluator interface {
CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) (map[int64]bool, error)
}
func (r *Router) onUsersGetSavedMusic(ctx context.Context, req *tg.UsersGetSavedMusicRequest) (tg.UsersSavedMusicClass, error) {
if req == nil || req.Offset < 0 || req.Limit < 0 || req.Limit > maxSavedMusicLimit {
return nil, limitInvalidErr()
}
currentUserID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
owner, found, err := r.userFromInput(ctx, currentUserID, req.ID)
if err != nil {
if errors.Is(err, users.ErrNotAuthorized) {
return emptyUsersSavedMusic(), nil
}
return nil, internalErr()
}
if !found {
return nil, userIDInvalidErr()
}
allowed, err := r.canSeeSavedMusic(ctx, currentUserID, owner.ID)
if err != nil {
return nil, err
}
if !allowed || r.deps.Account == nil {
return emptyUsersSavedMusic(), nil
}
list, err := r.deps.Account.ListSavedMusic(ctx, owner.ID, req.Offset, req.Limit)
if err != nil {
return nil, internalErr()
}
if req.Hash != 0 && req.Offset == 0 && int64(tdesktopCountHash(savedMusicDocumentIDs(list.Documents))) == req.Hash {
return &tg.UsersSavedMusicNotModified{Count: list.Count}, nil
}
return &tg.UsersSavedMusic{
Count: list.Count,
Documents: tgDocuments(list.Documents),
}, nil
}
func (r *Router) onUsersGetSavedMusicByID(ctx context.Context, req *tg.UsersGetSavedMusicByIDRequest) (tg.UsersSavedMusicClass, error) {
if req == nil || len(req.Documents) > maxSavedMusicLimit {
return nil, limitInvalidErr()
}
currentUserID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
owner, found, err := r.userFromInput(ctx, currentUserID, req.ID)
if err != nil {
if errors.Is(err, users.ErrNotAuthorized) {
return emptyUsersSavedMusic(), nil
}
return nil, internalErr()
}
if !found {
return nil, userIDInvalidErr()
}
allowed, err := r.canSeeSavedMusic(ctx, currentUserID, owner.ID)
if err != nil {
return nil, err
}
if !allowed || r.deps.Account == nil || len(req.Documents) == 0 {
return emptyUsersSavedMusic(), nil
}
ids := make([]int64, 0, len(req.Documents))
accessHashes := make(map[int64]int64, len(req.Documents))
for _, input := range req.Documents {
doc, ok := input.(*tg.InputDocument)
if !ok || doc.ID == 0 {
return nil, documentInvalidErr()
}
ids = append(ids, doc.ID)
if _, seen := accessHashes[doc.ID]; !seen {
accessHashes[doc.ID] = doc.AccessHash
}
}
list, err := r.deps.Account.GetSavedMusicByIDs(ctx, owner.ID, ids)
if err != nil {
return nil, internalErr()
}
docs := list.Documents[:0]
for _, doc := range list.Documents {
if doc.AccessHash == accessHashes[doc.ID] && doc.IsMusic() {
docs = append(docs, doc)
}
}
return &tg.UsersSavedMusic{
Count: list.Count,
Documents: tgDocuments(docs),
}, nil
}
func emptyUsersSavedMusic() *tg.UsersSavedMusic {
return &tg.UsersSavedMusic{
Count: 0,
Documents: []tg.DocumentClass{},
}
}
func (r *Router) canSeeSavedMusic(ctx context.Context, viewerUserID, ownerUserID int64) (bool, error) {
if viewerUserID == 0 || ownerUserID == 0 || viewerUserID == ownerUserID || r.deps.Privacy == nil {
return true, nil
}
allowed, err := r.deps.Privacy.CanSee(ctx, ownerUserID, viewerUserID, domain.PrivacyKeySavedMusic)
if err != nil {
return false, internalErr()
}
return allowed, nil
}
func savedMusicDocumentIDs(docs []domain.Document) []int64 {
ids := make([]int64, 0, len(docs))
for _, doc := range docs {
ids = append(ids, doc.ID)
}
return ids
}
func (r *Router) fillUserFullPhotos(ctx context.Context, viewerUserID, ownerUserID int64, profileAllowed bool, 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))
}
}
}
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
}
// tgBotInfo 构造 userFull.bot_info。bot 用户必须携带(哪怕命令为空):缺失时
// TDesktop 的 botInfo.inited 永不置位,每次开聊/输 "/" 都会重拉 getFullUser
// user_id 必填且必须等于该 bot 的 id不匹配会被客户端整体静默忽略。
func (r *Router) tgBotInfo(ctx context.Context, u domain.User) tg.BotInfo {
info := tgBotInfoFromProfile(u.ID, domain.BotProfile{}, false)
if r.deps.Bots != nil {
if profile, found, err := r.deps.Bots.BotInfo(ctx, u.ID); err == nil && found {
info = tgBotInfoFromProfile(u.ID, profile, true)
}
}
// botInfo#4d8a0299 verifier_settings:flags.9 -- present only for a bot that is
// itself an enabled verifier, and independent of the bot profile above.
r.applyVerifierSettingsToOneBotInfo(ctx, &info, u.ID)
return info
}
type botProfileBatchResolver interface {
BotInfos(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error)
}
func (r *Router) tgBotInfos(ctx context.Context, userIDs []int64) []tg.BotInfo {
if len(userIDs) == 0 {
return nil
}
ids := uniquePeerIDs(userIDs)
profiles := map[int64]domain.BotProfile{}
if r.deps.Bots != nil {
if batch, ok := r.deps.Bots.(botProfileBatchResolver); ok {
if loaded, err := batch.BotInfos(ctx, ids); err == nil {
profiles = loaded
}
} else {
for _, id := range ids {
if profile, found, err := r.deps.Bots.BotInfo(ctx, id); err == nil && found {
profiles[id] = profile
}
}
}
}
// One verifier-settings query for the whole bot list, next to the profile batch
// above: channelFull.bot_info must not turn into an N+1 just to carry
// verifier_settings:flags.9.
verifiers := r.verifierSettingsBatch(ctx, ids)
out := make([]tg.BotInfo, 0, len(userIDs))
for _, id := range userIDs {
profile, found := profiles[id]
info := tgBotInfoFromProfile(id, profile, found)
if settings, ok := verifiers[id]; ok {
applyVerifierSettingsToBotInfo(&info, id, settings)
}
out = append(out, info)
}
return out
}
func tgBotInfoFromProfile(userID int64, profile domain.BotProfile, found bool) tg.BotInfo {
info := tg.BotInfo{}
info.SetUserID(userID)
info.SetMenuButton(&tg.BotMenuButtonDefault{})
if !found {
return info
}
if profile.Description != "" {
info.SetDescription(profile.Description)
}
if len(profile.Commands) > 0 {
cmds := make([]tg.BotCommand, 0, len(profile.Commands))
for _, c := range profile.Commands {
cmds = append(cmds, tg.BotCommand{
Command: c.Command,
Description: c.Description,
Ephemeral: c.Ephemeral,
})
}
info.SetCommands(cmds)
}
info.SetMenuButton(tgBotMenuButton(profile.MenuButton))
if profile.HasPreviewMedias {
info.SetHasPreviewMedias(true)
}
if profile.AppSettings != nil {
info.SetAppSettings(tgBotAppSettings(*profile.AppSettings))
}
return info
}
func tgBotAppSettings(in domain.BotAppSettings) tg.BotAppSettings {
out := tg.BotAppSettings{}
if len(in.PlaceholderPath) > 0 {
out.SetPlaceholderPath(append([]byte(nil), in.PlaceholderPath...))
}
if in.HasBackgroundColor {
out.SetBackgroundColor(in.BackgroundColor)
}
if in.HasBackgroundDark {
out.SetBackgroundDarkColor(in.BackgroundDarkColor)
}
if in.HasHeaderColor {
out.SetHeaderColor(in.HeaderColor)
}
if in.HasHeaderDarkColor {
out.SetHeaderDarkColor(in.HeaderDarkColor)
}
return out
}
func emptyUserFull() *tg.UsersUserFull {
return &tg.UsersUserFull{
FullUser: tg.UserFull{
Settings: tg.PeerSettings{},
NotifySettings: *tdesktop.NotifySettings(),
},
}
}
func (r *Router) userFromInput(ctx context.Context, currentUserID int64, id tg.InputUserClass) (domain.User, bool, error) {
switch v := id.(type) {
case *tg.InputUserSelf:
u, err := r.deps.Users.Self(ctx, currentUserID)
return u, err == nil, err
case *tg.InputUser:
u, found, err := r.deps.Users.ByID(ctx, currentUserID, v.UserID)
if err != nil || !found {
return domain.User{}, found, err
}
if v.AccessHash != 0 && v.AccessHash != u.AccessHash {
return domain.User{}, false, nil
}
return u, true, nil
default:
return domain.User{}, false, nil
}
}
func (r *Router) validateInputUser(ctx context.Context, id tg.InputUserClass) error {
if r.deps.Users == nil {
return nil
}
currentUserID, _, err := r.currentUserID(ctx)
if err != nil {
return internalErr()
}
_, found, err := r.userFromInput(ctx, currentUserID, id)
if err != nil {
if errors.Is(err, users.ErrNotAuthorized) {
return userIDInvalidErr()
}
return internalErr()
}
if !found {
return userIDInvalidErr()
}
return nil
}