package rpc import ( "context" "errors" "sort" "strconv" "strings" "unicode/utf8" "github.com/iamxvbaba/td/tg" "telesrv/internal/domain" ) func (r *Router) onChannelsExportMessageLink(ctx context.Context, req *tg.ChannelsExportMessageLinkRequest) (*tg.ExportedMessageLink, error) { if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { return nil, messageIDInvalidErr() } userID, view, err := r.channelView(ctx, req.Channel) if err != nil { return nil, err } history, err := r.deps.Channels.GetMessages(ctx, userID, view.Channel.ID, []int{req.ID}) if err != nil { return nil, channelInvalidErr(err) } if len(history.Messages) != 1 || history.Messages[0].ID != req.ID { return nil, messageIDInvalidErr() } link := "" if view.Channel.Username != "" { link = r.publicLink(view.Channel.Username + "/" + strconv.Itoa(req.ID)) } else { link = r.publicLink("c/" + strconv.FormatInt(view.Channel.ID, 10) + "/" + strconv.Itoa(req.ID)) } if req.Thread { if rootID := channelMessageThreadRootID(history.Messages[0]); rootID > 0 && rootID != req.ID { link += "?thread=" + strconv.Itoa(rootID) } } return &tg.ExportedMessageLink{Link: link, HTML: ""}, nil } func channelMessageThreadRootID(msg domain.ChannelMessage) int { if msg.ReplyTo == nil { return 0 } if msg.ReplyTo.TopMessageID > 0 { return msg.ReplyTo.TopMessageID } return msg.ReplyTo.MessageID } func readChannelMessageContentIDs(messages []domain.ChannelMessage) []int { if len(messages) == 0 { return nil } ids := make([]int, 0, len(messages)) seen := make(map[int]struct{}, len(messages)) for _, msg := range messages { if msg.ID <= 0 { continue } if _, ok := seen[msg.ID]; ok { continue } seen[msg.ID] = struct{}{} ids = append(ids, msg.ID) } sort.Ints(ids) return ids } func (r *Router) onChannelsSearchPosts(ctx context.Context, req *tg.ChannelsSearchPostsRequest) (tg.MessagesMessagesClass, error) { userID, _, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } if err := validateChannelSearchPostsRequest(req); err != nil { return nil, err } offsetChannelID, err := r.searchPostsOffsetChannelID(ctx, userID, req.OffsetPeer) if err != nil { return nil, err } if r.deps.Channels == nil { return &tg.MessagesMessages{Messages: []tg.MessageClass{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil } hashtag, query := channelSearchPostsTerms(req) history, err := r.deps.Channels.SearchPosts(ctx, userID, domain.ChannelSearchPostsRequest{ Hashtag: hashtag, Query: query, OffsetRate: req.OffsetRate, OffsetChannelID: offsetChannelID, OffsetID: req.OffsetID, Limit: req.Limit, }) if err != nil { return nil, channelInvalidErr(err) } history = r.enrichChannelHistory(ctx, userID, history) r.maybeEnqueueExpiredChannelWebPageResolves(userID, history.Messages) result := tgChannelSearchPostsMessages(userID, history) r.applyPeerReadModelsToMessages(ctx, userID, result) return result, nil } func validateChannelSearchPostsRequest(req *tg.ChannelsSearchPostsRequest) error { if req == nil { return inputRequestInvalidErr() } if req.Limit < 0 || req.Limit > maxChannelSearchPostsLimit { return limitInvalidErr() } if req.OffsetRate < 0 { return limitInvalidErr() } if req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID { return messageIDInvalidErr() } if req.AllowPaidStars < 0 { return limitInvalidErr() } hashtag, hasHashtag, query, hasQuery := channelSearchPostsTermsWithFlags(req) if hasHashtag == hasQuery { return searchQueryEmptyErr() } if hasHashtag { if strings.TrimSpace(hashtag) == "" { return searchQueryEmptyErr() } if strings.Contains(hashtag, "#") || utf8.RuneCountInString(hashtag) > maxChannelSearchPostsQuery { return limitInvalidErr() } } if hasQuery { if strings.TrimSpace(query) == "" { return searchQueryEmptyErr() } if utf8.RuneCountInString(query) > maxChannelSearchPostsQuery { return limitInvalidErr() } } return nil } func channelSearchPostsTerms(req *tg.ChannelsSearchPostsRequest) (hashtag, query string) { hashtag, _, query, _ = channelSearchPostsTermsWithFlags(req) return strings.TrimSpace(hashtag), strings.TrimSpace(query) } func channelSearchPostsTermsWithFlags(req *tg.ChannelsSearchPostsRequest) (hashtag string, hasHashtag bool, query string, hasQuery bool) { hashtag, hasHashtag = req.GetHashtag() if !hasHashtag && req.Hashtag != "" { hashtag, hasHashtag = req.Hashtag, true } query, hasQuery = req.GetQuery() if !hasQuery && req.Query != "" { query, hasQuery = req.Query, true } return hashtag, hasHashtag, query, hasQuery } func (r *Router) searchPostsOffsetChannelID(ctx context.Context, userID int64, peer tg.InputPeerClass) (int64, error) { if peer == nil { return 0, nil } if _, ok := peer.(*tg.InputPeerEmpty); ok { return 0, nil } out, ok := r.domainPeerFromInputPeer(userID, peer) if !ok || out.ID == 0 { return 0, peerIDInvalidErr() } if out.Type != domain.PeerTypeChannel { return 0, peerIDInvalidErr() } ref, ok := inputPeerChannelRef(peer) if !ok || !ref.CheckAccessHash || r.deps.Channels == nil { return out.ID, nil } view, err := r.deps.Channels.GetChannel(ctx, userID, out.ID) if err == nil { if !inputChannelAccessHashMatches(ref, view.Channel) { return 0, channelInvalidErr(domain.ErrChannelPrivate) } return out.ID, nil } if !errors.Is(err, domain.ErrChannelPrivate) { return 0, channelInvalidErr(err) } channel, joinErr := r.deps.Channels.GetJoinableChannel(ctx, userID, ref.ID) if joinErr != nil || channel.Username == "" || !inputChannelAccessHashMatches(ref, channel) { return 0, channelInvalidErr(err) } return out.ID, nil } func tgChannelSearchPostsMessages(viewerUserID int64, history domain.ChannelHistory) tg.MessagesMessagesClass { messages := make([]tg.MessageClass, 0, len(history.Messages)) for _, msg := range history.Messages { if item := tgChannelMessage(viewerUserID, msg); item != nil { messages = append(messages, item) } } chats := tgChannels(viewerUserID, history.Channels) users := tgUsersForViewer(viewerUserID, history.Users) // viewer 自己的帖子作者须带 self 标志 if history.Count > len(messages) { out := &tg.MessagesMessagesSlice{ Count: history.Count, Messages: messages, Topics: []tg.ForumTopicClass{}, Chats: chats, Users: users, } if len(history.Messages) > 0 { out.SetNextRate(history.Messages[len(history.Messages)-1].Date) } out.SetSearchFlood(tg.SearchPostsFlood{QueryIsFree: true, TotalDaily: 100, Remains: 100, StarsAmount: 0}) return out } return &tg.MessagesMessages{Messages: messages, Topics: []tg.ForumTopicClass{}, Chats: chats, Users: users} } func (r *Router) onChannelsCheckSearchPostsFlood(ctx context.Context, req *tg.ChannelsCheckSearchPostsFloodRequest) (*tg.SearchPostsFlood, error) { if _, _, err := r.currentUserID(ctx); err != nil { return nil, internalErr() } if err := validateChannelCheckSearchPostsFloodRequest(req); err != nil { return nil, err } return &tg.SearchPostsFlood{QueryIsFree: true, TotalDaily: 100, Remains: 100, StarsAmount: 0}, nil } func validateChannelCheckSearchPostsFloodRequest(req *tg.ChannelsCheckSearchPostsFloodRequest) error { if req == nil { return inputRequestInvalidErr() } query, hasQuery := req.GetQuery() if !hasQuery && req.Query != "" { query, hasQuery = req.Query, true } if !hasQuery || strings.TrimSpace(query) == "" { return searchQueryEmptyErr() } if utf8.RuneCountInString(query) > maxChannelSearchPostsQuery { return limitInvalidErr() } return nil } func (r *Router) onChannelsGetMessages(ctx context.Context, req *tg.ChannelsGetMessagesRequest) (tg.MessagesMessagesClass, error) { if len(req.ID) > domain.MaxGetMessageIDs { return nil, limitInvalidErr() } if r.deps.Channels == nil || len(req.ID) == 0 { return &tg.MessagesMessages{}, nil } userID, _, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) if err != nil { return nil, err } if err := r.checkFrozenChannelParticipants(ctx, userID, channelID); err != nil { return nil, err } trace := newGetMessagesInputTrace(req.ID) ids := trace.lookupIDs if len(ids) == 0 { r.logChannelGetMessagesTrace(ctx, channelID, trace, nil, &tg.MessagesMessages{}) return &tg.MessagesMessages{}, nil } history, err := r.deps.Channels.GetMessages(ctx, userID, channelID, ids) if err != nil { return nil, channelInvalidErr(err) } history = r.enrichChannelHistory(ctx, userID, history) byID := make(map[int]domain.ChannelMessage, len(history.Messages)) for _, msg := range history.Messages { byID[msg.ID] = msg } r.maybeEnqueueExpiredChannelWebPageResolves(userID, history.Messages) messages := make([]tg.MessageClass, 0, len(ids)) for _, id := range ids { if msg, ok := byID[id]; ok { messages = append(messages, tgChannelMessage(userID, msg)) } else { messages = append(messages, &tg.MessageEmpty{ID: id}) } } result := &tg.MessagesMessages{ Messages: messages, Chats: tgChannels(userID, []domain.Channel{history.Channel}), Users: r.tgUsersForViewer(userID, history.Users), // viewer 补拉自己的消息(含置顶)须带 self } r.applyPeerReadModelsToMessages(ctx, userID, result) r.logChannelGetMessagesTrace(ctx, channelID, trace, history.Messages, result) return result, nil } func (r *Router) onChannelsDeleteMessages(ctx context.Context, req *tg.ChannelsDeleteMessagesRequest) (*tg.MessagesAffectedMessages, error) { if len(req.ID) == 0 { return &tg.MessagesAffectedMessages{PtsCount: 0}, nil } if len(req.ID) > domain.MaxDeleteMessageIDs { return nil, limitInvalidErr() } for _, id := range req.ID { if id <= 0 || id > domain.MaxMessageBoxID { return nil, messageIDInvalidErr() } } if r.deps.Channels == nil { return nil, notImplementedErr() } userID, _, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) if err != nil { return nil, err } res, err := r.deps.Channels.DeleteMessages(ctx, userID, domain.DeleteChannelMessagesRequest{ UserID: userID, ChannelID: channelID, IDs: append([]int(nil), req.ID...), Date: int(r.clock.Now().Unix()), }) if err != nil { return nil, channelDeleteErr(err) } if res.Event.Pts != 0 { // 删除 fan-out 异步化(设计 Phase 0)。channelDeleteMessagesUpdates 是纯 CPU 构建 // (不碰 PG、不取 ctx),async 无竞态;同 channel 串行保 pts 单调。 r.enqueueChannelFanout(ctx, channelFanoutMessageBox, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) }) // 被删 broadcast post 的讨论组转发根级联删除同样要让讨论组成员收敛。 for _, cascade := range res.DiscussionDeletes { cascade := cascade r.enqueueChannelFanout(ctx, channelFanoutMessageBox, userID, cascade.Channel.ID, cascade.Event.Pts, cascade.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { return r.channelDeleteMessagesUpdates(viewerUserID, cascade.Channel, cascade.Event) }) } return &tg.MessagesAffectedMessages{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount}, nil } return &tg.MessagesAffectedMessages{Pts: res.Channel.Pts, PtsCount: 0}, nil } // NotifyModerationChannelDeletion performs the online accelerator for a // server-authority deletion already committed by the moderation action worker. // Durable channel update events remain the offline recovery source. func (r *Router) NotifyModerationChannelDeletion(ctx context.Context, res domain.DeleteChannelMessagesResult) { if r == nil || res.Event.Pts == 0 { return } r.enqueueChannelFanout(ctx, channelFanoutMessageBox, domain.OfficialSystemUserID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) }) for _, cascade := range res.DiscussionDeletes { cascade := cascade r.enqueueChannelFanout(ctx, channelFanoutMessageBox, domain.OfficialSystemUserID, cascade.Channel.ID, cascade.Event.Pts, cascade.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { return r.channelDeleteMessagesUpdates(viewerUserID, cascade.Channel, cascade.Event) }) } } func (r *Router) onChannelsDeleteHistory(ctx context.Context, req *tg.ChannelsDeleteHistoryRequest) (tg.UpdatesClass, error) { if r.deps.Channels == nil { return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil } userID, _, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() } channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) if err != nil { return nil, err } if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID { return nil, messageIDInvalidErr() } res, err := r.deps.Channels.DeleteHistory(ctx, userID, domain.DeleteChannelHistoryRequest{ UserID: userID, ChannelID: channelID, MaxID: req.MaxID, ForEveryone: req.GetForEveryone(), Date: int(r.clock.Now().Unix()), }) if err != nil { return nil, channelDeleteErr(err) } if res.Event.Pts == 0 { updates := r.channelAvailableMessagesUpdates(userID, res.Channel, res.AvailableMinID) if res.AvailableMinChanged { // updateChannelAvailableMessages is an absolute owner-local boundary // with no account/channel pts. Other online sessions consume it // immediately; future cold/offline sessions discover the same // boundary through account/channel difference recovery. r.pushUserUpdates(ctx, userID, updates) } return updates, nil } pushBatch := func(batch domain.DeleteChannelHistoryResult) *tg.Updates { out := r.channelDeleteMessagesUpdates(userID, batch.Channel, batch.Event) // 每批 fan-out 异步化;批次按 pts 递增顺序入同一 channel 分片 → FIFO 保单调。 r.enqueueChannelFanout(ctx, channelFanoutMessageBox, userID, batch.Channel.ID, batch.Event.Pts, batch.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { return r.channelDeleteMessagesUpdates(viewerUserID, batch.Channel, batch.Event) }) return out } updates := pushBatch(res) // channels.deleteHistory 返回 Updates,TL 层没有 affectedHistory.offset // 续删协议,客户端只发一次请求;超过单批上限的剩余历史必须由服务端 // 在本次请求内删完,否则"清空"后旧消息会残留。每批独立事务推进 pts // 并立即推送,中途失败时已删批次保持有效,返回最后成功批次的结果。 for res.Offset != 0 && ctx.Err() == nil { next, err := r.deps.Channels.DeleteHistory(ctx, userID, domain.DeleteChannelHistoryRequest{ UserID: userID, ChannelID: channelID, MaxID: req.MaxID, ForEveryone: req.GetForEveryone(), Date: int(r.clock.Now().Unix()), }) if err != nil || next.Event.Pts == 0 { break } res = next updates = pushBatch(res) } return updates, nil }