1011 lines
33 KiB
Go
1011 lines
33 KiB
Go
package rpc
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"github.com/iamxvbaba/td/tg"
|
||
"github.com/iamxvbaba/td/tgerr"
|
||
"sort"
|
||
"telesrv/internal/domain"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
func (r *Router) onMessagesSearchStickerSets(ctx context.Context, req *tg.MessagesSearchStickerSetsRequest) (tg.MessagesFoundStickerSetsClass, error) {
|
||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if utf8.RuneCountInString(req.Q) > maxStickerSearchQLength {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if req.Hash != 0 {
|
||
return &tg.MessagesFoundStickerSetsNotModified{}, nil
|
||
}
|
||
return &tg.MessagesFoundStickerSets{
|
||
Hash: 0,
|
||
Sets: []tg.StickerSetCoveredClass{},
|
||
}, nil
|
||
}
|
||
|
||
// onMessagesSearchEmojiStickerSets 之前完全未注册,Android 的 emoji 分类点击/搜索会
|
||
// 无限重试这个方法(每次都拿 NOT_IMPLEMENTED,客户端不认为这是终态错误,一直重发),
|
||
// 表现为分类点击后结果区永远不出来。和 onMessagesSearchStickerSets 同策略,返回合法空结果。
|
||
func (r *Router) onMessagesSearchEmojiStickerSets(ctx context.Context, req *tg.MessagesSearchEmojiStickerSetsRequest) (tg.MessagesFoundStickerSetsClass, error) {
|
||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if utf8.RuneCountInString(req.Q) > maxStickerSearchQLength {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if req.Hash != 0 {
|
||
return &tg.MessagesFoundStickerSetsNotModified{}, nil
|
||
}
|
||
return &tg.MessagesFoundStickerSets{
|
||
Hash: 0,
|
||
Sets: []tg.StickerSetCoveredClass{},
|
||
}, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesSearchStickers(ctx context.Context, req *tg.MessagesSearchStickersRequest) (tg.MessagesFoundStickersClass, error) {
|
||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if req.Offset < 0 || req.Offset > domain.MaxMessageBoxID || req.Limit < 0 || req.Limit > maxSearchResultsLimit {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if utf8.RuneCountInString(req.Q) > maxStickerSearchQLength || utf8.RuneCountInString(req.Emoticon) > maxStickerSearchQLength {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if len(req.LangCode) > maxStickerSearchLangs {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
for _, lang := range req.LangCode {
|
||
if err := validateEmojiLangCode(lang); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if req.Hash != 0 {
|
||
return &tg.MessagesFoundStickersNotModified{}, nil
|
||
}
|
||
return &tg.MessagesFoundStickers{
|
||
Hash: 0,
|
||
Stickers: []tg.DocumentClass{},
|
||
}, nil
|
||
}
|
||
|
||
func emoticonEmptyErr() error { return tgerr.New(400, "EMOTICON_EMPTY") }
|
||
|
||
// onMessagesSearchCustomEmoji 目前没有按 emoticon 反查自定义 emoji 的索引,故与
|
||
// onMessagesSearchStickers/onMessagesSearchStickerSets 同策略:返回合法的空结果而非
|
||
// NOT_IMPLEMENTED——之前完全未注册此方法时,Android 的 emoji 分类/搜索会拿到
|
||
// notImplementedErr(),表现为一直转圈(客户端没有为这个具体错误重置搜索 UI 状态)。
|
||
func (r *Router) onMessagesSearchCustomEmoji(ctx context.Context, req *tg.MessagesSearchCustomEmojiRequest) (tg.EmojiListClass, error) {
|
||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if req.Emoticon == "" {
|
||
return nil, emoticonEmptyErr()
|
||
}
|
||
if utf8.RuneCountInString(req.Emoticon) > maxStickerSearchQLength {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if req.Hash != 0 {
|
||
return &tg.EmojiListNotModified{}, nil
|
||
}
|
||
return &tg.EmojiList{Hash: 0, DocumentID: []int64{}}, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesGetSearchResultsCalendar(ctx context.Context, req *tg.MessagesGetSearchResultsCalendarRequest) (*tg.MessagesSearchResultsCalendar, error) {
|
||
if req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID || req.OffsetDate < 0 {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if err := r.validateSearchResultsPeer(ctx, req.Peer, req.GetSavedPeerID); err != nil {
|
||
return nil, err
|
||
}
|
||
minDate := req.OffsetDate
|
||
if minDate == 0 {
|
||
minDate = int(r.clock.Now().Unix())
|
||
}
|
||
return &tg.MessagesSearchResultsCalendar{
|
||
Count: 0,
|
||
MinDate: minDate,
|
||
MinMsgID: req.OffsetID,
|
||
Periods: []tg.SearchResultsCalendarPeriod{},
|
||
Messages: []tg.MessageClass{},
|
||
Chats: []tg.ChatClass{},
|
||
Users: []tg.UserClass{},
|
||
}, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesGetSearchResultsPositions(ctx context.Context, req *tg.MessagesGetSearchResultsPositionsRequest) (*tg.MessagesSearchResultsPositions, error) {
|
||
if req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID || req.Limit < 0 || req.Limit > maxSearchResultsLimit {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if err := r.validateSearchResultsPeer(ctx, req.Peer, req.GetSavedPeerID); err != nil {
|
||
return nil, err
|
||
}
|
||
return &tg.MessagesSearchResultsPositions{
|
||
Count: 0,
|
||
Positions: []tg.SearchResultPosition{},
|
||
}, nil
|
||
}
|
||
|
||
func (r *Router) validateSearchResultsPeer(ctx context.Context, peer tg.InputPeerClass, savedPeer func() (tg.InputPeerClass, bool)) error {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return internalErr()
|
||
}
|
||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil {
|
||
return err
|
||
}
|
||
if savedPeer == nil {
|
||
return nil
|
||
}
|
||
if input, ok := savedPeer(); ok && input != nil {
|
||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Router) onMessagesGetMessagesViews(ctx context.Context, req *tg.MessagesGetMessagesViewsRequest) (*tg.MessagesMessageViews, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if len(req.ID) > maxGetMessagesIDs {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
for _, msgID := range req.ID {
|
||
if msgID <= 0 || msgID > domain.MaxMessageBoxID {
|
||
return nil, messageIDInvalidErr()
|
||
}
|
||
}
|
||
views := make([]tg.MessageViews, len(req.ID))
|
||
peer, peerErr := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if peerErr != nil {
|
||
return nil, peerErr
|
||
}
|
||
if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil && len(req.ID) > 0 {
|
||
viewCounters, err := r.deps.Channels.GetMessageViews(ctx, userID, domain.ChannelMessageViewsRequest{
|
||
UserID: userID,
|
||
ChannelID: peer.ID,
|
||
IDs: req.ID,
|
||
Increment: req.Increment,
|
||
Date: int(r.clock.Now().Unix()),
|
||
})
|
||
if err != nil {
|
||
return nil, channelInvalidErr(err)
|
||
}
|
||
for i, id := range req.ID {
|
||
if count, ok := viewCounters.Views[id]; ok {
|
||
views[i].SetViews(count)
|
||
}
|
||
if replies := tgChannelMessageReplies(viewCounters.Replies[id]); replies != nil {
|
||
views[i].SetReplies(*replies)
|
||
}
|
||
}
|
||
channels, users := r.messageViewPeerObjects(ctx, userID, viewCounters)
|
||
return r.applyStoryMaxIDsToMessageViews(ctx, userID, &tg.MessagesMessageViews{
|
||
Views: views,
|
||
Chats: tgChannels(userID, channels),
|
||
Users: r.tgUsersForViewer(userID, users),
|
||
}), nil
|
||
}
|
||
return r.applyStoryMaxIDsToMessageViews(ctx, userID, &tg.MessagesMessageViews{
|
||
Views: views,
|
||
Chats: r.chatsForInputPeer(ctx, userID, req.Peer),
|
||
Users: []tg.UserClass{},
|
||
}), nil
|
||
}
|
||
|
||
func (r *Router) messageViewPeerObjects(ctx context.Context, viewerUserID int64, result domain.ChannelMessageViewsResult) ([]domain.Channel, []domain.User) {
|
||
channels := []domain.Channel{}
|
||
if result.Channel.ID != 0 {
|
||
channels = append(channels, result.Channel)
|
||
}
|
||
userIDs := make(map[int64]struct{})
|
||
channelIDs := make(map[int64]struct{})
|
||
for _, peer := range result.Peers {
|
||
addDomainPeerRef(peer, result.Channel.ID, userIDs, channelIDs)
|
||
}
|
||
for _, replies := range result.Replies {
|
||
if replies == nil {
|
||
continue
|
||
}
|
||
if replies.ChannelID != 0 && replies.ChannelID != result.Channel.ID {
|
||
channelIDs[replies.ChannelID] = struct{}{}
|
||
}
|
||
for _, peer := range replies.RecentRepliers {
|
||
addDomainPeerRef(peer, result.Channel.ID, userIDs, channelIDs)
|
||
}
|
||
}
|
||
removeKnownChannelRefs(channelIDs, channels)
|
||
cache := newViewerPeerCache(r)
|
||
channels = mergeDomainChannels(channels, cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...)
|
||
users := cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs))
|
||
return channels, users
|
||
}
|
||
|
||
func (r *Router) onMessagesGetSearchCounters(ctx context.Context, req *tg.MessagesGetSearchCountersRequest) ([]tg.MessagesSearchCounter, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if len(req.Filters) > maxMessageSearchFilters {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
needsPinned := false
|
||
needsMedia := false
|
||
for _, filter := range req.Filters {
|
||
if filter == nil {
|
||
continue
|
||
}
|
||
if _, ok := filter.(*tg.InputMessagesFilterPinned); ok {
|
||
needsPinned = true
|
||
continue
|
||
}
|
||
if len(mediaCategoriesForFilter(filter)) > 0 {
|
||
needsMedia = true
|
||
}
|
||
}
|
||
pinnedCount := 0
|
||
mediaCounts := domain.MediaCategoryCounts{}
|
||
if needsPinned {
|
||
switch {
|
||
case peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil:
|
||
history, err := r.deps.Channels.GetHistory(ctx, userID, domain.ChannelHistoryFilter{
|
||
ChannelID: peer.ID,
|
||
PinnedOnly: true,
|
||
NeedTotalCount: true,
|
||
CountOnly: true,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
pinnedCount = history.Count
|
||
case peer.Type == domain.PeerTypeUser && r.deps.Messages != nil:
|
||
list, err := r.deps.Messages.Search(ctx, userID, domain.MessageFilter{
|
||
HasPeer: true,
|
||
Peer: peer,
|
||
PinnedOnly: true,
|
||
Limit: 1,
|
||
NeedTotalCount: true,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
pinnedCount = list.Count
|
||
}
|
||
}
|
||
if needsMedia {
|
||
counts, err := r.mediaCountsForPeer(ctx, userID, peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
mediaCounts = counts
|
||
}
|
||
counters := make([]tg.MessagesSearchCounter, 0, len(req.Filters))
|
||
for _, filter := range req.Filters {
|
||
if filter == nil {
|
||
continue
|
||
}
|
||
count := 0
|
||
if _, ok := filter.(*tg.InputMessagesFilterPinned); ok {
|
||
count = pinnedCount
|
||
} else if categories := mediaCategoriesForFilter(filter); len(categories) > 0 {
|
||
count = mediaCounts.CountAny(categories)
|
||
}
|
||
counters = append(counters, tg.MessagesSearchCounter{Filter: filter, Count: count})
|
||
}
|
||
return counters, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesGetReplies(ctx context.Context, req *tg.MessagesGetRepliesRequest) (tg.MessagesMessagesClass, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
||
return nil, messageIDInvalidErr()
|
||
}
|
||
if err := validateHistoryBounds(req.OffsetID, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil {
|
||
return nil, err
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil {
|
||
replies, err := r.deps.Channels.GetReplies(ctx, userID, domain.ChannelRepliesFilter{
|
||
ChannelID: peer.ID,
|
||
RootMessageID: req.MsgID,
|
||
OffsetID: req.OffsetID,
|
||
OffsetDate: req.OffsetDate,
|
||
AddOffset: req.AddOffset,
|
||
Limit: req.Limit,
|
||
MaxID: req.MaxID,
|
||
MinID: req.MinID,
|
||
Hash: req.Hash,
|
||
})
|
||
if err != nil {
|
||
return nil, channelInvalidErr(err)
|
||
}
|
||
if req.Hash != 0 && replies.Hash == req.Hash {
|
||
return &tg.MessagesMessagesNotModified{Count: replies.Count}, nil
|
||
}
|
||
return r.tgChannelHistoryMessages(ctx, userID, r.enrichChannelHistory(ctx, userID, replies)), nil
|
||
}
|
||
result := &tg.MessagesMessages{
|
||
Messages: []tg.MessageClass{},
|
||
Chats: r.chatsForInputPeer(ctx, userID, req.Peer),
|
||
Users: []tg.UserClass{},
|
||
}
|
||
r.applyPeerReadModelsToMessages(ctx, userID, result)
|
||
return result, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesGetDiscussionMessage(ctx context.Context, req *tg.MessagesGetDiscussionMessageRequest) (*tg.MessagesDiscussionMessage, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
||
return nil, messageIDInvalidErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if peer.Type != domain.PeerTypeChannel {
|
||
return nil, peerIDInvalidErr()
|
||
}
|
||
if r.deps.Channels == nil {
|
||
return &tg.MessagesDiscussionMessage{Chats: r.chatsForInputPeer(ctx, userID, req.Peer)}, nil
|
||
}
|
||
discussion, err := r.deps.Channels.GetDiscussionMessage(ctx, userID, peer.ID, req.MsgID)
|
||
if err != nil {
|
||
return nil, channelInvalidErr(err)
|
||
}
|
||
// 解析讨论帖/回复作者的 user/channel 实体(store 不填 Users),否则客户端拿不到
|
||
// 作者实体需额外 getUser 兜底。
|
||
discussion = r.enrichChannelDiscussion(ctx, userID, discussion)
|
||
return r.tgMessagesDiscussionMessage(ctx, userID, discussion), nil
|
||
}
|
||
|
||
func (r *Router) onMessagesReadDiscussion(ctx context.Context, req *tg.MessagesReadDiscussionRequest) (bool, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return false, internalErr()
|
||
}
|
||
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID || req.ReadMaxID < 0 || req.ReadMaxID > domain.MaxMessageBoxID {
|
||
return false, messageIDInvalidErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if peer.Type != domain.PeerTypeChannel {
|
||
return false, peerIDInvalidErr()
|
||
}
|
||
if r.deps.Channels == nil {
|
||
return true, nil
|
||
}
|
||
now := int(r.clock.Now().Unix())
|
||
// forum 话题已读:req.MsgID 是话题 id,推进 per-topic 水位(不碰频道级,消除话题间已读串扰),
|
||
// 向自己其它设备下发 DiscussionInbox、向话题内发送者下发 DiscussionOutbox 回执。不经
|
||
// GetDiscussionMessage,避免话题根消息被裁剪/删除时整条已读 400(root 不存活不应阻塞标已读)。
|
||
topicRes, terr := r.deps.Channels.ReadTopicHistory(ctx, userID, domain.ReadChannelTopicHistoryRequest{
|
||
UserID: userID,
|
||
ChannelID: peer.ID,
|
||
TopicID: req.MsgID,
|
||
MaxID: req.ReadMaxID,
|
||
Date: now,
|
||
})
|
||
if terr == nil {
|
||
if topicRes.Changed {
|
||
if err := r.recordChannelDiscussionInbox(ctx, userID, peer.ID, topicRes.TopicID, topicRes.MaxID, topicRes.Pts); err != nil {
|
||
return false, err
|
||
}
|
||
r.pushChannelDiscussionOutboxUpdates(ctx, peer.ID, topicRes.TopicID, topicRes.OutboxUpdates)
|
||
}
|
||
// 保守叠加:同时推进频道级 inbox 水位,保持 getDialogs/getPeerDialogs 会话总未读不退化。
|
||
if read, rerr := r.deps.Channels.ReadHistory(ctx, userID, domain.ReadChannelHistoryRequest{
|
||
UserID: userID,
|
||
ChannelID: peer.ID,
|
||
MaxID: req.ReadMaxID,
|
||
Date: now,
|
||
}); rerr == nil {
|
||
if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil {
|
||
return false, err
|
||
}
|
||
r.pushChannelReadOutboxUpdates(ctx, read.ChannelID, read.OutboxUpdates)
|
||
}
|
||
return topicRes.Changed, nil
|
||
}
|
||
if !errors.Is(terr, domain.ErrChannelForumMissing) {
|
||
return false, channelInvalidErr(terr)
|
||
}
|
||
// 非 forum(频道-讨论组 linked comments):只解析 target/root/boundary,禁止为了一个
|
||
// 已读请求加载完整 discussion message、reply stats、reactions 与 unread aggregates。
|
||
readChannelID := peer.ID
|
||
if provider, ok := r.deps.Channels.(interface {
|
||
ResolveDiscussionReadTarget(context.Context, int64, int64, int, int) (domain.ChannelDiscussionReadTarget, error)
|
||
}); ok {
|
||
target, resolveErr := provider.ResolveDiscussionReadTarget(ctx, userID, peer.ID, req.MsgID, req.ReadMaxID)
|
||
if resolveErr != nil {
|
||
return false, channelInvalidErr(resolveErr)
|
||
}
|
||
if target.AlreadyRead {
|
||
return false, nil
|
||
}
|
||
if target.Guest {
|
||
// Linked discussion guests have no channel_members/dialog row. Reading
|
||
// comments is therefore an authorized, durable-state-free no-op.
|
||
return false, nil
|
||
}
|
||
readChannelID = target.ChannelID
|
||
} else {
|
||
discussion, resolveErr := r.deps.Channels.GetDiscussionMessage(ctx, userID, peer.ID, req.MsgID)
|
||
if resolveErr != nil {
|
||
return false, channelInvalidErr(resolveErr)
|
||
}
|
||
if discussion.DiscussionChannel.ID != 0 {
|
||
readChannelID = discussion.DiscussionChannel.ID
|
||
}
|
||
}
|
||
read, err := r.deps.Channels.ReadHistory(ctx, userID, domain.ReadChannelHistoryRequest{
|
||
UserID: userID,
|
||
ChannelID: readChannelID,
|
||
MaxID: req.ReadMaxID,
|
||
Date: now,
|
||
})
|
||
if err != nil {
|
||
return false, channelInvalidErr(err)
|
||
}
|
||
if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil {
|
||
return false, err
|
||
}
|
||
r.pushChannelReadOutboxUpdates(ctx, read.ChannelID, read.OutboxUpdates)
|
||
return read.Changed, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesGetOnlines(ctx context.Context, peer tg.InputPeerClass) (*tg.ChatOnlines, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
domainPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if domainPeer.Type == domain.PeerTypeChannel && domainPeer.ID != 0 {
|
||
return &tg.ChatOnlines{Onlines: r.channelOnlineCount(ctx, userID, domainPeer.ID)}, nil
|
||
}
|
||
return &tg.ChatOnlines{Onlines: 1}, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesGetMessages(ctx context.Context, ids []tg.InputMessageClass) (tg.MessagesMessagesClass, error) {
|
||
if len(ids) > maxGetMessagesIDs {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if r.deps.Messages == nil || len(ids) == 0 {
|
||
return &tg.MessagesMessages{}, nil
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
|
||
out := make([]tg.MessageClass, 0, len(ids))
|
||
requestedIDs := make([]int, 0, len(ids))
|
||
for _, input := range ids {
|
||
id, ok := inputMessageBoxID(input)
|
||
if !ok || id <= 0 || id > domain.MaxMessageBoxID {
|
||
continue
|
||
}
|
||
requestedIDs = append(requestedIDs, id)
|
||
}
|
||
list, err := r.deps.Messages.GetMessages(ctx, userID, requestedIDs)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
foundByID := make(map[int]domain.Message, len(list.Messages))
|
||
for _, msg := range list.Messages {
|
||
foundByID[msg.ID] = msg
|
||
}
|
||
found := make([]domain.Message, 0, len(list.Messages))
|
||
for _, input := range ids {
|
||
id, ok := inputMessageBoxID(input)
|
||
if !ok || id <= 0 || id > domain.MaxMessageBoxID {
|
||
out = append(out, &tg.MessageEmpty{ID: id})
|
||
continue
|
||
}
|
||
msg, ok := foundByID[id]
|
||
if !ok {
|
||
out = append(out, &tg.MessageEmpty{ID: id})
|
||
continue
|
||
}
|
||
found = append(found, msg)
|
||
out = append(out, tgMessage(msg))
|
||
}
|
||
chats := r.chatsForMessageUpdates(ctx, userID, found)
|
||
result := &tg.MessagesMessages{
|
||
Messages: out,
|
||
Users: r.usersForMessageUpdates(ctx, userID, found),
|
||
Chats: chats,
|
||
}
|
||
r.applyPeerReadModelsToMessages(ctx, userID, result)
|
||
return result, nil
|
||
}
|
||
|
||
// onMessagesGetRichMessage 返回单条消息的完整富文本(Layer 227 richMessage)。消息列表
|
||
// 投影里已带 richMessage(tgMessage),本 RPC 是客户端按 (peer,id) 拉取完整富文本的入口。
|
||
// Phase 1 仅私聊:按 box id 从请求者自己的消息盒取出并投影;频道侧富文本留 Phase 2。
|
||
func (r *Router) onMessagesGetRichMessage(ctx context.Context, req *tg.MessagesGetRichMessageRequest) (tg.MessagesMessagesClass, error) {
|
||
if r.deps.Messages == nil || req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
|
||
return &tg.MessagesMessages{}, nil
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if userID == 0 {
|
||
return nil, peerIDInvalidErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if peer.Type == domain.PeerTypeChannel {
|
||
// 频道富文本留 Phase 2(channel_message 富文本列已备未接线)。
|
||
return &tg.MessagesMessages{}, nil
|
||
}
|
||
list, err := r.deps.Messages.GetMessages(ctx, userID, []int{req.ID})
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
out := make([]tg.MessageClass, 0, 1)
|
||
found := make([]domain.Message, 0, 1)
|
||
for _, msg := range list.Messages {
|
||
if msg.ID != req.ID || msg.Peer != peer {
|
||
continue
|
||
}
|
||
found = append(found, msg)
|
||
out = append(out, tgMessage(msg))
|
||
}
|
||
if len(out) == 0 {
|
||
out = append(out, &tg.MessageEmpty{ID: req.ID})
|
||
}
|
||
result := &tg.MessagesMessages{
|
||
Messages: out,
|
||
Users: r.usersForMessageUpdates(ctx, userID, found),
|
||
Chats: r.chatsForMessageUpdates(ctx, userID, found),
|
||
}
|
||
r.applyPeerReadModelsToMessages(ctx, userID, result)
|
||
return result, nil
|
||
}
|
||
|
||
func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSearchGlobalRequest) (tg.MessagesMessagesClass, error) {
|
||
if req.BroadcastsOnly && req.GroupsOnly {
|
||
return &tg.MessagesMessages{}, nil
|
||
}
|
||
query := normalizeSearchQuery(req.Q)
|
||
musicOnly := messagesSearchFilterMusic(req.Filter)
|
||
communityInput, hasCommunity := req.GetCommunity()
|
||
if !hasCommunity && req.Community != nil {
|
||
communityInput, hasCommunity = req.Community, true
|
||
}
|
||
emptyCommunitySearch := query == "" && !musicOnly && hasCommunity && messagesSearchFilterEmpty(req.Filter)
|
||
if query == "" && !musicOnly && !emptyCommunitySearch {
|
||
return nil, searchQueryEmptyErr()
|
||
}
|
||
if utf8.RuneCountInString(query) > maxMessageSearchQLength {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
if !musicOnly && searchFilterNeedsMediaStore(req.Filter) {
|
||
return &tg.MessagesMessages{}, nil
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
var communityView *domain.CommunityView
|
||
var communityScope domain.CommunitySearchScope
|
||
if hasCommunity {
|
||
view, err := r.communityFromInput(ctx, userID, communityInput)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
scope, err := r.deps.Communities.SearchScope(ctx, userID, view.Community.ID)
|
||
if err != nil {
|
||
return nil, communityErr(err)
|
||
}
|
||
communityView, communityScope = &view, scope
|
||
}
|
||
limit := req.Limit
|
||
if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit {
|
||
limit = domain.MaxChannelGlobalSearchLimit
|
||
}
|
||
folderID, hasFolderID := req.GetFolderID()
|
||
if hasFolderID && folderID < 0 {
|
||
return nil, folderIDInvalidErr()
|
||
}
|
||
channelOffsetID, err := r.searchGlobalChannelOffsetID(ctx, userID, req.OffsetPeer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if emptyCommunitySearch {
|
||
result := appendCommunitySearchChat(&tg.MessagesMessages{}, communityView)
|
||
r.applyPeerReadModelsToMessages(ctx, userID, result)
|
||
return result, nil
|
||
}
|
||
var private domain.MessageList
|
||
if !req.BroadcastsOnly && !req.GroupsOnly && r.deps.Messages != nil {
|
||
filter := domain.MessageFilter{
|
||
Query: query,
|
||
OffsetID: req.OffsetID,
|
||
OffsetDate: req.OffsetRate,
|
||
Limit: limit + 1,
|
||
MusicOnly: musicOnly,
|
||
}
|
||
if communityView != nil {
|
||
filter.RestrictPeerIDs = true
|
||
filter.PeerIDs = communityScope.BotUserIDs
|
||
}
|
||
if req.MaxDate > 0 {
|
||
filter.OffsetDate = req.MaxDate
|
||
}
|
||
if req.UsersOnly || !req.BroadcastsOnly && !req.GroupsOnly {
|
||
private, err = r.deps.Messages.Search(ctx, userID, filter)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
private = r.enrichMessageList(ctx, userID, private)
|
||
}
|
||
}
|
||
if req.UsersOnly || r.deps.Channels == nil {
|
||
result := appendCommunitySearchChat(tgMessagesMessages(userID, r.enrichMessageList(ctx, userID, limitMessageList(private, limit))), communityView)
|
||
r.applyPeerReadModelsToMessages(ctx, userID, result)
|
||
return result, nil
|
||
}
|
||
channelHistory, err := r.deps.Channels.SearchJoinedMessages(ctx, userID, domain.ChannelGlobalSearchRequest{
|
||
Query: query,
|
||
ChannelIDs: communityScope.ChannelIDs,
|
||
RestrictChannelIDs: communityView != nil,
|
||
AllowPublicPreview: communityView != nil,
|
||
BroadcastsOnly: req.BroadcastsOnly,
|
||
GroupsOnly: req.GroupsOnly,
|
||
MusicOnly: musicOnly,
|
||
HasFolderID: hasFolderID,
|
||
FolderID: folderID,
|
||
OffsetRate: req.OffsetRate,
|
||
OffsetChannelID: channelOffsetID,
|
||
OffsetID: req.OffsetID,
|
||
MinDate: req.MinDate,
|
||
MaxDate: req.MaxDate,
|
||
Limit: limit,
|
||
})
|
||
if err != nil {
|
||
return nil, channelInvalidErr(err)
|
||
}
|
||
channelHistory = r.enrichChannelHistory(ctx, userID, channelHistory)
|
||
if req.BroadcastsOnly || req.GroupsOnly {
|
||
return appendCommunitySearchChat(r.tgGlobalChannelMessages(ctx, userID, limitChannelHistory(channelHistory, limit)), communityView), nil
|
||
}
|
||
return appendCommunitySearchChat(r.tgGlobalSearchMessages(ctx, userID, limit, private, channelHistory), communityView), nil
|
||
}
|
||
|
||
func appendCommunitySearchChat(result tg.MessagesMessagesClass, view *domain.CommunityView) tg.MessagesMessagesClass {
|
||
if result == nil || view == nil {
|
||
return result
|
||
}
|
||
chat := tgCommunityChat(*view)
|
||
switch out := result.(type) {
|
||
case *tg.MessagesMessages:
|
||
out.Chats = appendUniqueTGChats(out.Chats, chat)
|
||
case *tg.MessagesMessagesSlice:
|
||
out.Chats = appendUniqueTGChats(out.Chats, chat)
|
||
case *tg.MessagesChannelMessages:
|
||
out.Chats = appendUniqueTGChats(out.Chats, chat)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func limitMessageList(list domain.MessageList, limit int) domain.MessageList {
|
||
if limit <= 0 {
|
||
limit = domain.MaxChannelGlobalSearchLimit
|
||
}
|
||
if len(list.Messages) > limit {
|
||
list.Messages = list.Messages[:limit]
|
||
list.Count = limit + 1
|
||
}
|
||
return list
|
||
}
|
||
|
||
func limitChannelHistory(history domain.ChannelHistory, limit int) domain.ChannelHistory {
|
||
if limit <= 0 {
|
||
limit = domain.MaxChannelGlobalSearchLimit
|
||
}
|
||
if len(history.Messages) > limit {
|
||
history.Messages = history.Messages[:limit]
|
||
history.Count = limit + 1
|
||
}
|
||
return history
|
||
}
|
||
|
||
func tgGlobalChannelMessages(viewerUserID int64, history domain.ChannelHistory) tg.MessagesMessagesClass {
|
||
out := tgChannelHistoryMessages(viewerUserID, history)
|
||
if slice, ok := out.(*tg.MessagesMessagesSlice); ok && len(history.Messages) > 0 {
|
||
slice.SetNextRate(history.Messages[len(history.Messages)-1].Date)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func tgGlobalSearchMessages(viewerUserID int64, limit int, private domain.MessageList, channel domain.ChannelHistory) tg.MessagesMessagesClass {
|
||
if limit <= 0 {
|
||
limit = domain.MaxChannelGlobalSearchLimit
|
||
}
|
||
hits := make([]globalSearchHit, 0, len(private.Messages)+len(channel.Messages))
|
||
for _, msg := range private.Messages {
|
||
item := tgMessage(msg)
|
||
if item == nil {
|
||
continue
|
||
}
|
||
hits = append(hits, globalSearchHit{
|
||
date: msg.Date,
|
||
peerRank: msg.Peer.ID,
|
||
messageID: msg.ID,
|
||
message: item,
|
||
})
|
||
}
|
||
for _, msg := range channel.Messages {
|
||
item := tgChannelMessage(viewerUserID, msg)
|
||
if item == nil {
|
||
continue
|
||
}
|
||
hits = append(hits, globalSearchHit{
|
||
date: msg.Date,
|
||
peerRank: msg.ChannelID,
|
||
messageID: msg.ID,
|
||
message: item,
|
||
})
|
||
}
|
||
sort.Slice(hits, func(i, j int) bool {
|
||
a, b := hits[i], hits[j]
|
||
if a.date != b.date {
|
||
return a.date > b.date
|
||
}
|
||
if a.peerRank != b.peerRank {
|
||
return a.peerRank > b.peerRank
|
||
}
|
||
return a.messageID > b.messageID
|
||
})
|
||
hasMore := private.Count > len(private.Messages) || channel.Count > len(channel.Messages) || len(hits) > limit
|
||
if len(hits) > limit {
|
||
hits = hits[:limit]
|
||
}
|
||
messages := make([]tg.MessageClass, 0, len(hits))
|
||
for _, hit := range hits {
|
||
messages = append(messages, hit.message)
|
||
}
|
||
// 全局搜索命中自己发的消息时 viewer 自己会出现在 users 里,须带 self 标志。
|
||
users := append(tgUsersForViewer(viewerUserID, private.Users), tgUsersForViewer(viewerUserID, channel.Users)...)
|
||
chats := tgChannels(viewerUserID, channel.Channels)
|
||
if hasMore {
|
||
out := &tg.MessagesMessagesSlice{
|
||
Count: limit + 1,
|
||
Messages: messages,
|
||
Chats: chats,
|
||
Users: users,
|
||
}
|
||
if len(hits) > 0 {
|
||
out.SetNextRate(hits[len(hits)-1].date)
|
||
}
|
||
return out
|
||
}
|
||
return &tg.MessagesMessages{Messages: messages, Chats: chats, Users: users}
|
||
}
|
||
|
||
func (r *Router) messageFilterFromHistoryRequest(userID int64, req *tg.MessagesGetHistoryRequest) (domain.MessageFilter, bool) {
|
||
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
|
||
if !ok {
|
||
return domain.MessageFilter{}, false
|
||
}
|
||
limit := req.Limit
|
||
if limit > 50 {
|
||
limit = 50
|
||
}
|
||
return domain.MessageFilter{
|
||
HasPeer: true,
|
||
Peer: peer,
|
||
OffsetID: req.OffsetID,
|
||
OffsetDate: req.OffsetDate,
|
||
AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset),
|
||
Limit: limit,
|
||
MaxID: req.MaxID,
|
||
MinID: req.MinID,
|
||
Hash: req.Hash,
|
||
}, true
|
||
}
|
||
|
||
func (r *Router) messageFilterFromSearchRequest(ctx context.Context, userID int64, req *tg.MessagesSearchRequest) (domain.MessageFilter, error) {
|
||
limit := req.Limit
|
||
if limit > 500 {
|
||
limit = 500
|
||
}
|
||
filter := domain.MessageFilter{
|
||
Query: req.Q,
|
||
OffsetID: req.OffsetID,
|
||
MinDate: req.MinDate,
|
||
MaxDate: req.MaxDate,
|
||
AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset),
|
||
Limit: limit,
|
||
MaxID: req.MaxID,
|
||
MinID: req.MinID,
|
||
Hash: req.Hash,
|
||
MusicOnly: messagesSearchFilterMusic(req.Filter),
|
||
NeedTotalCount: req.OffsetID == 0 && req.MinDate == 0 && req.MaxDate == 0 && req.AddOffset >= 0 && req.Hash == 0,
|
||
}
|
||
if peer, ok := r.domainPeerFromInputPeer(userID, req.Peer); ok {
|
||
filter.HasPeer = true
|
||
filter.Peer = peer
|
||
}
|
||
savedReactions, hasSavedReactions := req.GetSavedReaction()
|
||
// An empty optional vector carries no reaction-filtering semantics. Some TL
|
||
// clients emit flags.3 with a zero-length vector on ordinary peer searches.
|
||
// Keep the wire presence intact at the TL edge, but only apply Saved
|
||
// Messages scope and reaction validation when the vector has values.
|
||
hasSavedReactionFilter := hasSavedReactions && len(savedReactions) > 0
|
||
savedPeerInput, hasSavedPeer := req.GetSavedPeerID()
|
||
if hasSavedReactionFilter || hasSavedPeer {
|
||
if !filter.HasPeer ||
|
||
filter.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) {
|
||
return domain.MessageFilter{}, peerIDInvalidErr()
|
||
}
|
||
}
|
||
if hasSavedPeer {
|
||
if savedPeerInput == nil {
|
||
return domain.MessageFilter{}, peerIDInvalidErr()
|
||
}
|
||
savedPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, savedPeerInput)
|
||
if err != nil {
|
||
return domain.MessageFilter{}, err
|
||
}
|
||
filter.SavedPeer = savedPeer
|
||
}
|
||
if hasSavedReactionFilter {
|
||
if len(savedReactions) > maxReactionVector {
|
||
return domain.MessageFilter{}, reactionInvalidErr()
|
||
}
|
||
seen := make(map[string]struct{}, len(savedReactions))
|
||
for _, item := range savedReactions {
|
||
reaction, err := domainMessageReactionFromTL(item)
|
||
if err != nil {
|
||
return domain.MessageFilter{}, err
|
||
}
|
||
if _, ok := seen[reaction.Key()]; ok {
|
||
continue
|
||
}
|
||
seen[reaction.Key()] = struct{}{}
|
||
filter.SavedReactions = append(filter.SavedReactions, reaction)
|
||
}
|
||
}
|
||
return filter, nil
|
||
}
|
||
|
||
func (r *Router) channelHistoryFilterFromSearchRequest(userID int64, req *tg.MessagesSearchRequest, channelID int64) (domain.ChannelHistoryFilter, bool) {
|
||
limit := req.Limit
|
||
countOnly := limit == 0
|
||
if limit <= 0 || limit > 100 {
|
||
limit = 100
|
||
}
|
||
filter := domain.ChannelHistoryFilter{
|
||
ChannelID: channelID,
|
||
Query: req.Q,
|
||
PinnedOnly: messagesSearchFilterPinned(req.Filter),
|
||
MusicOnly: messagesSearchFilterMusic(req.Filter),
|
||
NeedTotalCount: countOnly ||
|
||
(req.OffsetID == 0 && req.MinDate == 0 && req.MaxDate == 0 && req.AddOffset >= 0 && req.Hash == 0),
|
||
CountOnly: countOnly,
|
||
OffsetID: req.OffsetID,
|
||
AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset),
|
||
Limit: limit,
|
||
MinDate: req.MinDate,
|
||
MaxDate: req.MaxDate,
|
||
MaxID: req.MaxID,
|
||
MinID: req.MinID,
|
||
Hash: req.Hash,
|
||
}
|
||
if req.FromID != nil {
|
||
from, ok := r.domainPeerFromInputPeer(userID, req.FromID)
|
||
if !ok || from.Type != domain.PeerTypeUser || from.ID == 0 {
|
||
return domain.ChannelHistoryFilter{}, false
|
||
}
|
||
filter.SenderUserID = from.ID
|
||
}
|
||
return filter, true
|
||
}
|
||
|
||
func messagesSearchFilterPinned(filter tg.MessagesFilterClass) bool {
|
||
_, ok := filter.(*tg.InputMessagesFilterPinned)
|
||
return ok
|
||
}
|
||
|
||
func messagesSearchFilterMusic(filter tg.MessagesFilterClass) bool {
|
||
_, ok := filter.(*tg.InputMessagesFilterMusic)
|
||
return ok
|
||
}
|
||
|
||
func messagesSearchFilterEmpty(filter tg.MessagesFilterClass) bool {
|
||
_, ok := filter.(*tg.InputMessagesFilterEmpty)
|
||
return ok
|
||
}
|
||
|
||
func messagesSearchFilterChatPhotos(filter tg.MessagesFilterClass) bool {
|
||
_, ok := filter.(*tg.InputMessagesFilterChatPhotos)
|
||
return ok
|
||
}
|
||
|
||
func searchFilterNeedsMediaStore(filter tg.MessagesFilterClass) bool {
|
||
switch filter.(type) {
|
||
case nil, *tg.InputMessagesFilterEmpty:
|
||
return false
|
||
case *tg.InputMessagesFilterPhotos,
|
||
*tg.InputMessagesFilterVideo,
|
||
*tg.InputMessagesFilterPhotoVideo,
|
||
*tg.InputMessagesFilterDocument,
|
||
*tg.InputMessagesFilterMusic,
|
||
*tg.InputMessagesFilterURL,
|
||
*tg.InputMessagesFilterGif,
|
||
*tg.InputMessagesFilterVoice,
|
||
*tg.InputMessagesFilterRoundVoice,
|
||
*tg.InputMessagesFilterRoundVideo,
|
||
*tg.InputMessagesFilterPoll:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// mediaCategoriesForFilter 把客户端共享媒体标签页过滤器映射为媒体索引的基础类别并集。
|
||
// 复合标签页(PhotoVideo / RoundVoice)映射为多个基础类别;返回 nil 表示该过滤器不走媒体索引。
|
||
func mediaCategoriesForFilter(filter tg.MessagesFilterClass) []domain.MediaCategory {
|
||
switch filter.(type) {
|
||
case *tg.InputMessagesFilterPhotos:
|
||
return []domain.MediaCategory{domain.MediaCategoryPhoto}
|
||
case *tg.InputMessagesFilterVideo:
|
||
return []domain.MediaCategory{domain.MediaCategoryVideo}
|
||
case *tg.InputMessagesFilterPhotoVideo:
|
||
return []domain.MediaCategory{domain.MediaCategoryPhoto, domain.MediaCategoryVideo}
|
||
case *tg.InputMessagesFilterDocument:
|
||
return []domain.MediaCategory{domain.MediaCategoryFile}
|
||
case *tg.InputMessagesFilterMusic:
|
||
return []domain.MediaCategory{domain.MediaCategoryMusic}
|
||
case *tg.InputMessagesFilterURL:
|
||
return []domain.MediaCategory{domain.MediaCategoryURL}
|
||
case *tg.InputMessagesFilterGif:
|
||
return []domain.MediaCategory{domain.MediaCategoryGif}
|
||
case *tg.InputMessagesFilterVoice:
|
||
return []domain.MediaCategory{domain.MediaCategoryVoice}
|
||
case *tg.InputMessagesFilterRoundVideo:
|
||
return []domain.MediaCategory{domain.MediaCategoryRoundVideo}
|
||
case *tg.InputMessagesFilterRoundVoice:
|
||
return []domain.MediaCategory{domain.MediaCategoryVoice, domain.MediaCategoryRoundVideo}
|
||
case *tg.InputMessagesFilterPoll:
|
||
return []domain.MediaCategory{domain.MediaCategoryPoll}
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func messagesNotModifiedOrEmpty(hash int64) tg.MessagesMessagesClass {
|
||
if hash != 0 {
|
||
return &tg.MessagesMessagesNotModified{Count: 0}
|
||
}
|
||
return &tg.MessagesMessages{}
|
||
}
|