From 2014c983868f5930aedaf3b19c0a58b11fd73bff Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 9 Sep 2026 14:43:59 +0100 Subject: [PATCH] forum: let non-members preview topic replies in a public channel ListChannelReplies used getChannelForMemberOrLinkedGuest, so messages.getReplies was member-only. ListChannelHistory (flat history) uses getChannelForViewer and already allows a public channel's non-members to preview it. The mismatch meant that on a public forum you could preview the flat history but not the topics - and after leaving, tdesktop's topic view got CHANNEL_PRIVATE and sat on "Loading..." forever instead of rendering a preview. Switch the primary channel lookup in ListChannelReplies (both stores) to the viewer-scope path. Private channels still return CHANNEL_PRIVATE to non-members; the broadcast comment-thread lookup is unchanged. --- internal/rpc/forum_topics_preview_rpc_test.go | 20 +++++++++++++++++++ internal/store/memory/channel_topics.go | 4 +++- internal/store/postgres/channel_topics.go | 4 +++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/rpc/forum_topics_preview_rpc_test.go b/internal/rpc/forum_topics_preview_rpc_test.go index 8bceefba..3dd6b8ba 100644 --- a/internal/rpc/forum_topics_preview_rpc_test.go +++ b/internal/rpc/forum_topics_preview_rpc_test.go @@ -67,10 +67,14 @@ func TestGetForumTopicsVisibleToPublicNonMember(t *testing.T) { t.Fatalf("getForumTopics as non-member: %v", err) } titles := map[string]bool{} + testTopicID := 0 for _, tc := range res.Topics { switch topic := tc.(type) { case *tg.ForumTopic: titles[topic.Title] = true + if topic.Title == "Test" { + testTopicID = topic.ID + } case *tg.ForumTopicDeleted: } } @@ -81,6 +85,19 @@ func TestGetForumTopicsVisibleToPublicNonMember(t *testing.T) { t.Fatalf("non-member did not see the Test topic: %+v", res.Topics) } + // A non-member can also read the replies inside a topic (preview), the same + // way ListChannelHistory lets them preview a public group's flat history. + if testTopicID == 0 { + t.Fatal("no Test topic id to open") + } + if _, err := r.onMessagesGetReplies(outsiderCtx, &tg.MessagesGetRepliesRequest{ + Peer: forumPeer, + MsgID: testTopicID, + Limit: 20, + }); err != nil { + t.Fatalf("getReplies as non-member of a public forum: %v", err) + } + // The forum's own channel must come back with left=true so the client still // offers a Join button instead of treating the forum as already joined. var forumChat *tg.Channel @@ -110,4 +127,7 @@ func TestGetForumTopicsVisibleToPublicNonMember(t *testing.T) { if _, err := r.onMessagesGetForumTopics(outsiderCtx, &tg.MessagesGetForumTopicsRequest{Peer: privPeer, Limit: 100}); err == nil { t.Fatal("non-member read a private forum's topic list") } + if _, err := r.onMessagesGetReplies(outsiderCtx, &tg.MessagesGetRepliesRequest{Peer: privPeer, MsgID: 1, Limit: 20}); err == nil { + t.Fatal("non-member read a private forum topic's replies") + } } diff --git a/internal/store/memory/channel_topics.go b/internal/store/memory/channel_topics.go index f8f76d41..173c4a37 100644 --- a/internal/store/memory/channel_topics.go +++ b/internal/store/memory/channel_topics.go @@ -501,7 +501,9 @@ func (s *ChannelStore) GetForumTopicsByID(_ context.Context, viewerUserID, chann func (s *ChannelStore) ListChannelReplies(_ context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) { s.mu.RLock() defer s.mu.RUnlock() - source, member, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, filter.ChannelID) + // Viewer scope (not strict membership): non-members can preview topic + // replies in a public channel/supergroup, matching ListChannelHistory. + source, member, _, err := s.channelForViewerLocked(viewerUserID, filter.ChannelID) if err != nil { return domain.ChannelHistory{}, err } diff --git a/internal/store/postgres/channel_topics.go b/internal/store/postgres/channel_topics.go index 92297ab2..a3b3fc7c 100644 --- a/internal/store/postgres/channel_topics.go +++ b/internal/store/postgres/channel_topics.go @@ -588,7 +588,9 @@ ORDER BY pinned DESC, pinned_order DESC, date DESC, topic_id DESC`, channelID, m } func (s *ChannelStore) ListChannelReplies(ctx context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) { - source, member, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, filter.ChannelID) + // Viewer口径(非严格 member):公开频道/超级群的非成员可预览话题回复,与 + // ListChannelHistory 一致。私有频道非成员仍是 ErrChannelPrivate。 + source, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, filter.ChannelID) if err != nil { return domain.ChannelHistory{}, err }