fix: sync linked discussion guest support

This commit is contained in:
A 2026-07-11 23:18:24 +08:00
parent 5f7c0b9804
commit 9f73dc20da
27 changed files with 1004 additions and 30 deletions

View file

@ -123,6 +123,7 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
r.applyNotifySettingsToChannelFull(ctx, userID, ref.ID, &full)
r.applyAndroidChannelReactionEditorCompat(ctx, &full, cached.canChangeInfo)
chats := append([]tg.ChatClass(nil), cached.chats...)
chats = r.appendLinkedDiscussionChat(ctx, userID, ref.ID, chats)
r.trackChannelInterest(ctx, userID, ref.ID)
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
return &tg.MessagesChatFull{
@ -148,6 +149,7 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
}
r.trackChannelInterest(ctx, userID, view.Channel.ID)
chats := []tg.ChatClass{tgChannelChatForView(userID, view)}
chats = r.appendLinkedDiscussionChat(ctx, userID, view.Channel.ID, chats)
if mono, ok := r.linkedMonoforumForChannelState(ctx, userID, view.Channel); ok {
chats = appendUniqueTGChats(chats, tgChannelChat(userID, mono, nil))
}

View file

@ -16,6 +16,124 @@ import (
"testing"
)
type countingDiscussionReadChannels struct {
ChannelsService
delegate *appchannels.Service
resolveCalls int
getDiscussionCalls int
}
type emptyDiscussionBotProfiles struct{}
func (emptyDiscussionBotProfiles) BotInfo(context.Context, int64) (domain.BotProfile, bool, error) {
return domain.BotProfile{}, false, nil
}
func TestLinkedDiscussionGuestCanCommentWithoutMembership(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
owner, _ := users.Create(ctx, domain.User{AccessHash: 301, Phone: "15550003001", FirstName: "Owner"})
subscriber, _ := users.Create(ctx, domain.User{AccessHash: 302, Phone: "15550003002", FirstName: "Subscriber"})
channelStore := memory.NewChannelStore()
channels := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(emptyDiscussionBotProfiles{}))
r := New(Config{}, Deps{Users: appusers.NewService(users), Channels: channels}, zaptest.NewLogger(t), clock.System)
broadcast, err := channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "Private channel", Broadcast: true, Date: 1700003001})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
group, err := channels.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{Title: "Comments", Date: 1700003002})
if err != nil {
t.Fatalf("create discussion group: %v", err)
}
if _, err := channels.InviteToChannel(ctx, owner.ID, broadcast.Channel.ID, []int64{subscriber.ID}, 1700003003); err != nil {
t.Fatalf("invite broadcast subscriber: %v", err)
}
inputBroadcast := &tg.InputChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}
inputGroup := &tg.InputChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}
if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, owner.ID), &tg.ChannelsSetDiscussionGroupRequest{Broadcast: inputBroadcast, Group: inputGroup}); err != nil || !ok {
t.Fatalf("link discussion group = %v, %v", ok, err)
}
postUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, Message: "post", RandomID: 3001,
})
if err != nil {
t.Fatalf("send post: %v", err)
}
post := postUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
discussion, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, subscriber.ID), &tg.MessagesGetDiscussionMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, MsgID: post.ID,
})
if err != nil || len(discussion.Messages) != 1 {
t.Fatalf("get discussion as subscriber: messages=%d err=%v", len(discussion.Messages), err)
}
root := discussion.Messages[0].(*tg.Message)
req := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}, Message: "guest comment", RandomID: 3002,
}
req.SetReplyTo(&tg.InputReplyToMessage{ReplyToMsgID: root.ID})
if _, err := r.onMessagesSendMessage(WithUserID(ctx, subscriber.ID), req); err != nil {
t.Fatalf("send linked guest comment: %v", err)
}
if _, err := r.onChannelsGetFullChannel(WithUserID(ctx, subscriber.ID), inputGroup); err != nil {
t.Fatalf("get linked group full as guest: %v", err)
}
participant, err := r.onChannelsGetParticipant(WithUserID(ctx, subscriber.ID), &tg.ChannelsGetParticipantRequest{
Channel: inputGroup, Participant: &tg.InputPeerSelf{},
})
if err != nil {
t.Fatalf("get linked guest participant: %v", err)
}
if _, ok := participant.Participant.(*tg.ChannelParticipantLeft); !ok {
t.Fatalf("linked guest participant = %T, want channelParticipantLeft", participant.Participant)
}
if _, err := r.onChannelsGetParticipants(WithUserID(ctx, subscriber.ID), &tg.ChannelsGetParticipantsRequest{
Channel: inputGroup, Filter: &tg.ChannelParticipantsRecent{}, Limit: 20,
}); err != nil {
t.Fatalf("get linked group participants as guest: %v", err)
}
if _, err := r.onChannelsGetParticipants(WithUserID(ctx, subscriber.ID), &tg.ChannelsGetParticipantsRequest{
Channel: inputGroup, Filter: &tg.ChannelParticipantsBots{}, Limit: 20,
}); err != nil {
t.Fatalf("get linked group bot participants as guest: %v", err)
}
if _, err := r.onMessagesGetReplies(WithUserID(ctx, subscriber.ID), &tg.MessagesGetRepliesRequest{
Peer: &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}, MsgID: root.ID, Limit: 20,
}); err != nil {
t.Fatalf("get linked group replies as guest: %v", err)
}
if changed, err := r.onMessagesReadDiscussion(WithUserID(ctx, subscriber.ID), &tg.MessagesReadDiscussionRequest{
Peer: &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}, MsgID: root.ID, ReadMaxID: group.Channel.TopMessageID,
}); err != nil || changed {
t.Fatalf("guest read discussion = changed %v err %v, want authorized no-op", changed, err)
}
guestView, err := channels.GetChannel(ctx, subscriber.ID, group.Channel.ID)
if err != nil || !guestView.Self.Guest || guestView.Self.Status != domain.ChannelMemberLeft {
t.Fatalf("guest view after send = %+v err %v, want non-persisted left guest", guestView.Self, err)
}
if _, err := channels.SetJoinToSend(ctx, owner.ID, group.Channel.ID, true); err != nil {
t.Fatalf("enable join_to_send: %v", err)
}
req.RandomID = 3003
if _, err := r.onMessagesSendMessage(WithUserID(ctx, subscriber.ID), req); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") {
t.Fatalf("guest send with join_to_send err = %v, want CHAT_WRITE_FORBIDDEN", err)
}
}
func (s *countingDiscussionReadChannels) ResolveDiscussionReadTarget(ctx context.Context, userID, sourceChannelID int64, sourceMessageID, readMaxID int) (domain.ChannelDiscussionReadTarget, error) {
s.resolveCalls++
return s.delegate.ResolveDiscussionReadTarget(ctx, userID, sourceChannelID, sourceMessageID, readMaxID)
}
func (s *countingDiscussionReadChannels) GetLinkedDiscussionChannel(ctx context.Context, userID, sourceChannelID int64) (domain.ChannelView, error) {
return s.delegate.GetLinkedDiscussionChannel(ctx, userID, sourceChannelID)
}
func (s *countingDiscussionReadChannels) GetDiscussionMessage(ctx context.Context, userID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) {
s.getDiscussionCalls++
return s.delegate.GetDiscussionMessage(ctx, userID, channelID, msgID)
}
func TestChannelDialogCarriesChannelPts(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
@ -448,11 +566,13 @@ func TestChannelDiscussionRepliesRPCUsesLinkedMegagroup(t *testing.T) {
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91, Phone: "15550002911", FirstName: "Owner"})
member, _ := userStore.Create(ctx, domain.User{AccessHash: 92, Phone: "15550002912", FirstName: "Member"})
subscriber, _ := userStore.Create(ctx, domain.User{AccessHash: 93, Phone: "15550002913", FirstName: "Subscriber"})
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
trackedChannels := &countingDiscussionReadChannels{ChannelsService: channelService, delegate: channelService}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Channels: trackedChannels,
}, zaptest.NewLogger(t), clock.System)
broadcast, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Discussion Source",
@ -478,6 +598,36 @@ func TestChannelDiscussionRepliesRPCUsesLinkedMegagroup(t *testing.T) {
}); err != nil || !ok {
t.Fatalf("set discussion group = ok %v err %v, want true", ok, err)
}
if _, err := channelService.InviteToChannel(ctx, owner.ID, broadcast.Channel.ID, []int64{subscriber.ID}, 1700002913); err != nil {
t.Fatalf("invite subscriber to broadcast: %v", err)
}
guestView, err := channelService.GetChannel(ctx, subscriber.ID, group.Channel.ID)
if err != nil {
t.Fatalf("linked private-group lookup for subscriber: %v", err)
}
if !guestView.Self.Guest || guestView.Self.Status != domain.ChannelMemberLeft {
t.Fatalf("linked private-group self = %+v, want computed left guest", guestView.Self)
}
linkedView, err := channelService.GetLinkedDiscussionChannel(ctx, subscriber.ID, broadcast.Channel.ID)
if err != nil {
t.Fatalf("linked discussion projection for subscriber: %v", err)
}
if linkedView.Channel.ID != group.Channel.ID || linkedView.Self.Status != domain.ChannelMemberLeft {
t.Fatalf("linked discussion view = %+v, want group %d with left membership", linkedView, group.Channel.ID)
}
fullForSubscriber, err := r.onChannelsGetFullChannel(WithUserID(ctx, subscriber.ID), inputBroadcast)
if err != nil {
t.Fatalf("get full broadcast for subscriber: %v", err)
}
var fullLinked *tg.Channel
for _, chat := range fullForSubscriber.Chats {
if channel, ok := chat.(*tg.Channel); ok && channel.ID == group.Channel.ID {
fullLinked = channel
}
}
if fullLinked == nil || !fullLinked.Left {
t.Fatalf("subscriber full chats = %+v, want linked group %d projected as left", fullForSubscriber.Chats, group.Channel.ID)
}
postUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash},
@ -491,6 +641,61 @@ func TestChannelDiscussionRepliesRPCUsesLinkedMegagroup(t *testing.T) {
if !post.Post {
t.Fatalf("broadcast post = %#v, want channel post", post)
}
historyDomain, err := channelService.GetHistory(ctx, subscriber.ID, domain.ChannelHistoryFilter{
ChannelID: broadcast.Channel.ID,
Limit: 10,
})
if err != nil {
t.Fatalf("get broadcast history for subscriber: %v", err)
}
historyForSubscriber := r.tgChannelHistoryMessages(ctx, subscriber.ID, r.enrichChannelHistory(ctx, subscriber.ID, historyDomain))
_, historyChats, _ := searchMessagesPayload(t, historyForSubscriber)
var historyLinked *tg.Channel
for _, chat := range historyChats {
if channel, ok := chat.(*tg.Channel); ok && channel.ID == group.Channel.ID {
historyLinked = channel
}
}
if historyLinked == nil || !historyLinked.Left {
t.Fatalf("subscriber history chats = %+v, want linked group %d projected as left", historyChats, group.Channel.ID)
}
historyMessages, _, _ := searchMessagesPayload(t, historyForSubscriber)
historyPost := historyMessages[0].(*tg.Message)
if replies, ok := historyPost.GetReplies(); !ok || !replies.Comments {
t.Fatalf("subscriber history post replies = %+v ok %v, want comments", replies, ok)
}
discussionForSubscriber, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, subscriber.ID), &tg.MessagesGetDiscussionMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash},
MsgID: post.ID,
})
if err != nil {
t.Fatalf("get discussion message for subscriber: %v", err)
}
var discussionLinked *tg.Channel
for _, chat := range discussionForSubscriber.Chats {
if channel, ok := chat.(*tg.Channel); ok && channel.ID == group.Channel.ID {
discussionLinked = channel
}
}
if discussionLinked == nil || !discussionLinked.Left {
t.Fatalf("subscriber discussion chats = %+v, want linked group %d projected as left", discussionForSubscriber.Chats, group.Channel.ID)
}
if _, err := channelService.InviteToChannel(ctx, owner.ID, group.Channel.ID, []int64{subscriber.ID}, 1700002914); err != nil {
t.Fatalf("invite subscriber to linked group: %v", err)
}
fullAfterJoin, err := r.onChannelsGetFullChannel(WithUserID(ctx, subscriber.ID), inputBroadcast)
if err != nil {
t.Fatalf("get cached full broadcast after linked-group join: %v", err)
}
var joinedLinked *tg.Channel
for _, chat := range fullAfterJoin.Chats {
if channel, ok := chat.(*tg.Channel); ok && channel.ID == group.Channel.ID {
joinedLinked = channel
}
}
if joinedLinked == nil || joinedLinked.Left {
t.Fatalf("cached full chats after join = %+v, want refreshed active linked group %d", fullAfterJoin.Chats, group.Channel.ID)
}
discussion, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash},
MsgID: post.ID,
@ -585,6 +790,15 @@ func TestChannelDiscussionRepliesRPCUsesLinkedMegagroup(t *testing.T) {
if maxID, ok := replyInfo.GetMaxID(); !ok || maxID != comment.ID {
t.Fatalf("message views max_id = %d ok %v, want %d", maxID, ok, comment.ID)
}
readTarget, err := channelService.ResolveDiscussionReadTarget(ctx, owner.ID, broadcast.Channel.ID, post.ID, comment.ID)
if err != nil {
t.Fatalf("resolve discussion read target before read: %v", err)
}
if readTarget.ChannelID != group.Channel.ID || readTarget.RootID != root.ID || readTarget.AlreadyRead {
t.Fatalf("read target before read = %+v, want group/root and unread", readTarget)
}
trackedChannels.resolveCalls = 0
trackedChannels.getDiscussionCalls = 0
if ok, err := r.onMessagesReadDiscussion(WithUserID(ctx, owner.ID), &tg.MessagesReadDiscussionRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash},
MsgID: post.ID,
@ -592,6 +806,23 @@ func TestChannelDiscussionRepliesRPCUsesLinkedMegagroup(t *testing.T) {
}); err != nil || !ok {
t.Fatalf("read discussion = ok %v err %v, want true", ok, err)
}
if trackedChannels.resolveCalls != 1 || trackedChannels.getDiscussionCalls != 0 {
t.Fatalf("first read calls resolve=%d getDiscussion=%d, want narrow resolver only", trackedChannels.resolveCalls, trackedChannels.getDiscussionCalls)
}
readTarget, err = channelService.ResolveDiscussionReadTarget(ctx, owner.ID, broadcast.Channel.ID, post.ID, comment.ID)
if err != nil || !readTarget.AlreadyRead {
t.Fatalf("resolve discussion read target after read = %+v err %v, want already read", readTarget, err)
}
if changed, err := r.onMessagesReadDiscussion(WithUserID(ctx, owner.ID), &tg.MessagesReadDiscussionRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash},
MsgID: post.ID,
ReadMaxID: comment.ID,
}); err != nil || changed {
t.Fatalf("repeat read discussion = changed %v err %v, want idempotent false", changed, err)
}
if trackedChannels.resolveCalls != 2 || trackedChannels.getDiscussionCalls != 0 {
t.Fatalf("repeat read calls resolve=%d getDiscussion=%d, want narrow resolver only", trackedChannels.resolveCalls, trackedChannels.getDiscussionCalls)
}
afterRead, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash},
MsgID: post.ID,

View file

@ -42,7 +42,16 @@ func (r *Router) onUpdatesGetChannelDifference(ctx context.Context, req *tg.Upda
return nil, channelInvalidErr(err)
}
diff = r.enrichChannelDifference(ctx, userID, diff)
return tgChannelDifference(userID, diff), nil
out := tgChannelDifference(userID, diff)
if linked, ok := r.linkedDiscussionChat(ctx, userID, channelID); ok {
switch value := out.(type) {
case *tg.UpdatesChannelDifference:
value.Chats = replaceTGChat(value.Chats, linked)
case *tg.UpdatesChannelDifferenceTooLong:
value.Chats = replaceTGChat(value.Chats, linked)
}
}
return out, nil
}
func (r *Router) channelOperationUpdates(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult) *tg.Updates {
@ -226,6 +235,7 @@ func (r *Router) channelMessagesUpdatesWithPeerCache(ctx context.Context, viewer
chats := []tg.ChatClass(nil)
if channel.ID != 0 {
chats = []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)}
chats = r.appendLinkedDiscussionChat(ctx, viewerUserID, channel.ID, chats)
}
chats = append(chats, tgChannels(viewerUserID, cache.channelsForIDs(ctx, viewerUserID, peerIDsExcept(peerIDMapKeys(channelIDs), channel.ID)))...)
if date == 0 {

View file

@ -449,7 +449,7 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
// 的 Creator/admin 无关。服务端的私信发送鉴权走母频道 membership,不依赖此 flag。
out.Creator = false
} else if self != nil {
if self.Status == domain.ChannelMemberLeft {
if self.Status == domain.ChannelMemberLeft || self.Guest {
out.Left = true
}
switch self.Role {

View file

@ -0,0 +1,65 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
type linkedDiscussionChannelProvider interface {
GetLinkedDiscussionChannel(ctx context.Context, userID, sourceChannelID int64) (domain.ChannelView, error)
}
// linkedDiscussionChat projects the linked megagroup through the source
// broadcast. Generic channel lookup must keep rejecting private non-members;
// this narrow path is what lets TDesktop resolve ChannelFull.linked_chat_id.
func (r *Router) linkedDiscussionChat(ctx context.Context, userID, sourceChannelID int64) (tg.ChatClass, bool) {
provider, ok := r.deps.Channels.(linkedDiscussionChannelProvider)
if !ok || userID == 0 || sourceChannelID == 0 {
return nil, false
}
view, err := provider.GetLinkedDiscussionChannel(ctx, userID, sourceChannelID)
if err != nil || view.Channel.ID == 0 {
return nil, false
}
return tgChannelChatForView(userID, view), true
}
func (r *Router) appendLinkedDiscussionChat(ctx context.Context, userID, sourceChannelID int64, chats []tg.ChatClass) []tg.ChatClass {
chat, ok := r.linkedDiscussionChat(ctx, userID, sourceChannelID)
if !ok {
return chats
}
return replaceTGChat(chats, chat)
}
func replaceTGChat(chats []tg.ChatClass, replacement tg.ChatClass) []tg.ChatClass {
wanted := tgChatID(replacement)
if wanted == 0 {
return chats
}
out := make([]tg.ChatClass, 0, len(chats)+1)
for _, chat := range chats {
if tgChatID(chat) != wanted {
out = append(out, chat)
}
}
return append(out, replacement)
}
func tgChatID(chat tg.ChatClass) int64 {
switch item := chat.(type) {
case *tg.Channel:
return item.ID
case *tg.ChannelForbidden:
return item.ID
case *tg.Chat:
return item.ID
case *tg.ChatForbidden:
return item.ID
default:
return 0
}
}

View file

@ -371,14 +371,33 @@ func (r *Router) onMessagesReadDiscussion(ctx context.Context, req *tg.MessagesR
if !errors.Is(terr, domain.ErrChannelForumMissing) {
return false, channelInvalidErr(terr)
}
// 非 forum频道-讨论组 linked comments保持原频道级已读链路。
discussion, err := r.deps.Channels.GetDiscussionMessage(ctx, userID, peer.ID, req.MsgID)
if err != nil {
return false, channelInvalidErr(err)
}
// 非 forum频道-讨论组 linked comments只解析 target/root/boundary禁止为了一个
// 已读请求加载完整 discussion message、reply stats、reactions 与 unread aggregates。
readChannelID := peer.ID
if discussion.DiscussionChannel.ID != 0 {
readChannelID = discussion.DiscussionChannel.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,

View file

@ -125,12 +125,25 @@ func (r *Router) tgMessagesMessages(ctx context.Context, viewerUserID int64, lis
func (r *Router) tgChannelHistoryMessages(ctx context.Context, viewerUserID int64, history domain.ChannelHistory) tg.MessagesMessagesClass {
out := tgChannelHistoryMessages(viewerUserID, history)
if linked, ok := r.linkedDiscussionChat(ctx, viewerUserID, history.Channel.ID); ok {
switch value := out.(type) {
case *tg.MessagesChannelMessages:
value.Chats = replaceTGChat(value.Chats, linked)
case *tg.MessagesMessagesSlice:
value.Chats = replaceTGChat(value.Chats, linked)
case *tg.MessagesMessages:
value.Chats = replaceTGChat(value.Chats, linked)
}
}
r.applyStoryMaxIDsToMessages(ctx, viewerUserID, out)
return out
}
func (r *Router) tgMessagesDiscussionMessage(ctx context.Context, viewerUserID int64, discussion domain.ChannelDiscussionMessage) *tg.MessagesDiscussionMessage {
out := tgMessagesDiscussionMessage(viewerUserID, discussion)
if linked, ok := r.linkedDiscussionChat(ctx, viewerUserID, discussion.PostChannel.ID); ok {
out.Chats = replaceTGChat(out.Chats, linked)
}
// 用带 presence + self 标志的投影覆盖裸 tgUsers防止 viewer 自己以 self=false
// 进入 UsersAndroid putUsers 会覆盖 currentUser
out.Users = r.tgUsersForViewer(viewerUserID, discussion.Users)