chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -12,11 +12,13 @@ import (
)
const maxSavedMusicLimit = 100
const maxRequirementsToContactUsers = 100
// registerUsers 注册 users.* RPC handler。
func (r *Router) registerUsers(d *tg.ServerDispatcher) {
d.OnUsersGetUsers(r.onUsersGetUsers)
d.OnUsersGetFullUser(r.onUsersGetFullUser)
d.OnUsersGetRequirementsToContact(r.onUsersGetRequirementsToContact)
d.OnUsersGetSavedMusic(r.onUsersGetSavedMusic)
d.OnUsersGetSavedMusicByID(r.onUsersGetSavedMusicByID)
}
@ -27,41 +29,86 @@ func (r *Router) onUsersGetUsers(ctx context.Context, ids []tg.InputUserClass) (
if err != nil {
return nil, internalErr()
}
out := make([]tg.UserClass, 0, len(ids))
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 {
if r.deps.Users == nil {
continue
}
switch v := in.(type) {
case *tg.InputUserSelf:
if !authorized {
continue
}
u, err := r.deps.Users.Self(ctx, currentUserID)
if err != nil {
if errors.Is(err, users.ErrNotAuthorized) {
continue // 未登录getUsers 尽力而为,跳过 self
}
return nil, internalErr()
}
out = append(out, r.tgSelfUser(u))
needSelf = true
inputs = append(inputs, userInput{self: true})
case *tg.InputUser:
if !authorized {
if !authorized || v.UserID == 0 {
continue
}
u, found, err := r.deps.Users.ByID(ctx, currentUserID, v.UserID)
if err != nil {
if errors.Is(err, users.ErrNotAuthorized) {
continue
}
return nil, internalErr()
}
if !found || (v.AccessHash != 0 && v.AccessHash != u.AccessHash) {
inputs = append(inputs, userInput{userID: v.UserID, accessHash: v.AccessHash})
if _, ok := seenUserIDs[v.UserID]; ok {
continue
}
out = append(out, r.tgUser(u))
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.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, out, nil)
return out, nil
}
@ -84,14 +131,45 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
return emptyUserFull(), nil
}
user := r.tgUser(u)
if _, ok := id.(*tg.InputUserSelf); ok {
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
}
r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, []tg.UserClass{user}, nil)
loadEpoch := r.userFullProjectionCache.LoadEpoch()
if full, ok := r.userFullProjectionCache.Lookup(currentUserID, u.ID); ok {
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
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)
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
return &tg.UsersUserFull{
FullUser: full,
Users: []tg.UserClass{user},
Chats: chats,
}, nil
}
func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int64, u domain.User) (tg.UserFull, error) {
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()
return tg.UserFull{}, internalErr()
}
if !allowed {
about = ""
@ -103,8 +181,94 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
Settings: tg.PeerSettings{},
NotifySettings: *tdesktop.NotifySettings(),
}
// 通话入口:客户端不见 phone_calls_available=true 不显示通话按钮P1 前置项)。
// phone_calls_private 标记对端禁 P2Pp2p_allowed 真值在通话确认时另行计算)。
if !u.Bot && u.ID != currentUserID {
callsAllowed, p2pAllowed := true, true
if r.deps.Privacy != nil {
allowed, err := r.deps.Privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyPhoneCall)
if err != nil {
return tg.UserFull{}, internalErr()
}
callsAllowed = allowed
allowed, err = r.deps.Privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyPhoneP2P)
if err != nil {
return tg.UserFull{}, internalErr()
}
p2pAllowed = allowed
}
full.PhoneCallsAvailable = callsAllowed
full.VideoCallsAvailable = callsAllowed
full.PhoneCallsPrivate = !p2pAllowed
}
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, &full); err != nil {
return nil, err
return tg.UserFull{}, err
}
if r.deps.Account != nil {
allowed, err := r.canSeeSavedMusic(ctx, currentUserID, u.ID)
if err != nil {
return tg.UserFull{}, err
}
if allowed {
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{
@ -114,26 +278,139 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
CountOnly: true,
})
if err != nil {
return nil, internalErr()
return tg.UserFull{}, internalErr()
}
full.CommonChatsCount = common.Count
}
return &tg.UsersUserFull{
FullUser: full,
Users: []tg.UserClass{user},
}, nil
// star gift 数量:客户端把资料页 Gifts 区段/标签页门控在 stargifts_count>0
//DrKLO ProfileActivity:10497 / TDesktop data_user.cpp:924不下发则收到的礼物
// 不在资料页展示。计展示在资料的礼物数(非转换、非隐藏)。
if r.deps.Gifts != nil {
if n, err := r.deps.Gifts.CountSaved(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}); err == nil && n > 0 {
full.SetStargiftsCount(n)
}
}
// 屏蔽 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() {
birthdayVisible := true
if r.deps.Privacy != nil && u.ID != currentUserID {
allowed, err := r.deps.Privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyBirthday)
if err != nil {
return tg.UserFull{}, internalErr()
}
birthdayVisible = allowed
}
if birthdayVisible {
full.SetBirthday(tgBirthday(u.Birthday))
}
}
// 个人频道account.updatePersonalChannel不在此落地它按 viewer 实时解析,作为缓存后的
// overlay 处理applyPersonalChannelToUserFull避免烤进 per-(viewer,target) 投影缓存以及
// build/chats 两次解析同一频道。
return full, 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()
}
out := make([]tg.RequirementToContactClass, 0, len(ids))
for range ids {
out = append(out, &tg.RequirementToContactEmpty{})
}
return out, nil
}
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()
}
if err := r.validateInputUser(ctx, req.ID); err != nil {
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: 0,
Documents: []tg.DocumentClass{},
Count: list.Count,
Documents: tgDocuments(list.Documents),
}, nil
}
@ -141,13 +418,79 @@ func (r *Router) onUsersGetSavedMusicByID(ctx context.Context, req *tg.UsersGetS
if req == nil || len(req.Documents) > maxSavedMusicLimit {
return nil, limitInvalidErr()
}
if err := r.validateInputUser(ctx, req.ID); err != nil {
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{},
}, nil
}
}
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, full *tg.UserFull) error {
@ -206,6 +549,98 @@ func (r *Router) fillUserFullPhotos(ctx context.Context, viewerUserID, ownerUser
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 {
if r.deps.Bots == nil {
return tgBotInfoFromProfile(u.ID, domain.BotProfile{}, false)
}
profile, found, err := r.deps.Bots.BotInfo(ctx, u.ID)
if err != nil || !found {
return tgBotInfoFromProfile(u.ID, domain.BotProfile{}, false)
}
return tgBotInfoFromProfile(u.ID, profile, true)
}
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
}
}
}
}
out := make([]tg.BotInfo, 0, len(userIDs))
for _, id := range userIDs {
profile, found := profiles[id]
out = append(out, tgBotInfoFromProfile(id, profile, found))
}
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})
}
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{