diff --git a/README.md b/README.md index 5648e135..85f3060c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ codebase. | ✅ | Private chats | Send, history, read receipts, edit, delete, forward, reply, rich entities, grouped/media messages, reactions, scheduled/TTL-oriented paths. | | ✅ | Rich messages | Telegram Desktop rich text messages, rich content conversion, send/edit/scheduled flows, dialog/history projections, and memory/PostgreSQL persistence. | | ✅ | AI compose and ChatBot | Input-box rewrite/polish, default and custom tones, addstyle previews, local and external provider chains, streamed `@ChatBot` draft replies, and Business AI reply hooks. | -| ✅ | Supergroups and channels | Create, join, leave, invite links, participants, admins, forum topics, history, send/edit/delete/read, reactions, public search, and previews. | +| ✅ | Supergroups and channels | Create, join, leave, invite links, participants, admins, forum topics, linked discussion guests, history, send/edit/delete/read, reactions, public search, and previews. | | ✅ | Media and files | Upload, download, local blob storage, photos, documents, thumbnails, canonical GIFv conversion, external media fetch, web page previews, map tile cache hooks, profile/channel photos. | | ✅ | Stickers and reactions | Sticker/reaction catalog, seed support, saved GIFs, recent reactions, top reactions, default reactions, and moderation-oriented reaction paths. | | ✅ | Gifts and stars | Star gifts and local stars ledger foundations for compatibility and future feature work. | diff --git a/README.zh-CN.md b/README.zh-CN.md index dff223ee..45876368 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,7 +45,7 @@ https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c | ✅ | 私聊消息 | send、history、read receipts、edit、delete、forward、reply、富文本实体、媒体/相册消息、reactions、scheduled/TTL 相关路径。 | | ✅ | 富文本消息 | Telegram Desktop rich text message、富文本内容转换、send/edit/scheduled 流程、dialog/history 投影,以及 memory/PostgreSQL 持久化。 | | ✅ | AI 输入框与 ChatBot | 输入框改写/润色、默认和自定义 tone、addstyle 预览、本地与外部 provider 链、流式 `@ChatBot` 草稿回复、Business AI 回复钩子。 | -| ✅ | 超级群与频道 | create、join、leave、邀请链接、成员、管理员、forum topics、history、send/edit/delete/read、reactions、公开搜索和预览。 | +| ✅ | 超级群与频道 | create、join、leave、邀请链接、成员、管理员、forum topics、关联讨论组 guest 访问、history、send/edit/delete/read、reactions、公开搜索和预览。 | | ✅ | 媒体与文件 | upload、download、本地 blob 存储、照片、文档、缩略图、规范 GIFv 转换、外链媒体抓取、网页预览、地图缩略图缓存、用户/频道头像。 | | ✅ | Stickers 与 Reactions | sticker/reaction catalog、seed 支持、saved GIFs、recent reactions、top reactions、default reactions、reaction moderation 相关路径。 | | ✅ | Gifts 与 Stars | star gifts、本地 stars ledger 基础,用于兼容和后续功能扩展。 | diff --git a/internal/app/channels/service.go b/internal/app/channels/service.go index 6ce6f327..43330a04 100644 --- a/internal/app/channels/service.go +++ b/internal/app/channels/service.go @@ -121,6 +121,40 @@ func (s *Service) GetChannel(ctx context.Context, userID, channelID int64) (doma return s.channels.GetChannel(ctx, userID, channelID) } +type linkedDiscussionChannelStore interface { + GetLinkedDiscussionChannel(ctx context.Context, viewerUserID, sourceChannelID int64) (domain.ChannelView, error) +} + +type discussionReadTargetStore interface { + ResolveDiscussionReadTarget(ctx context.Context, userID, sourceChannelID int64, sourceMessageID, readMaxID int) (domain.ChannelDiscussionReadTarget, error) +} + +// GetLinkedDiscussionChannel exposes the linked discussion peer to members of +// its source broadcast without broadening ordinary private-group visibility. +func (s *Service) GetLinkedDiscussionChannel(ctx context.Context, userID, sourceChannelID int64) (domain.ChannelView, error) { + if s == nil || s.channels == nil || userID == 0 || sourceChannelID == 0 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + provider, ok := s.channels.(linkedDiscussionChannelStore) + if !ok { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + return provider.GetLinkedDiscussionChannel(ctx, userID, sourceChannelID) +} + +// ResolveDiscussionReadTarget returns only the linked target/root/read boundary +// required by messages.readDiscussion, avoiding the full discussion payload. +func (s *Service) ResolveDiscussionReadTarget(ctx context.Context, userID, sourceChannelID int64, sourceMessageID, readMaxID int) (domain.ChannelDiscussionReadTarget, error) { + if s == nil || s.channels == nil || userID == 0 || sourceChannelID == 0 || sourceMessageID <= 0 || readMaxID < 0 { + return domain.ChannelDiscussionReadTarget{}, domain.ErrChannelInvalid + } + provider, ok := s.channels.(discussionReadTargetStore) + if !ok { + return domain.ChannelDiscussionReadTarget{}, domain.ErrChannelInvalid + } + return provider.ResolveDiscussionReadTarget(ctx, userID, sourceChannelID, sourceMessageID, readMaxID) +} + // GetChannelReadModel returns the full channel view through a version-token guarded // read model cache. It is intended for read-only RPC projection paths, not write // permission checks. diff --git a/internal/domain/channel.go b/internal/domain/channel.go index 504b7239..da8aa622 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -486,6 +486,10 @@ type ChannelMember struct { ReadOutboxMaxID int UnreadMark bool SlowmodeLastSendDate int + // Guest is a computed, non-persisted view used for subscribers accessing a + // private linked discussion group without joining it. Guest must never be + // written to channel_members and is still projected to clients as left. + Guest bool } // ChannelDialog is the current user's owner-view dialog state for a channel. @@ -834,6 +838,18 @@ type ChannelDiscussionRef struct { MessageID int } +// ChannelDiscussionReadTarget is the minimal authoritative projection needed +// by messages.readDiscussion. It intentionally excludes message/reply/reaction +// payloads used only by messages.getDiscussionMessage. +type ChannelDiscussionReadTarget struct { + ChannelID int64 + RootID int + AlreadyRead bool + // Guest means the viewer is authorized through the linked broadcast and has + // no discussion-group member row whose read boundary can be advanced. + Guest bool +} + // ChannelMessageReplies describes thread/comment counters without depending on TL types. type ChannelMessageReplies struct { Comments bool diff --git a/internal/rpc/channels_core.go b/internal/rpc/channels_core.go index 4ddd0500..8a37f2d5 100644 --- a/internal/rpc/channels_core.go +++ b/internal/rpc/channels_core.go @@ -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)) } diff --git a/internal/rpc/channels_dialogs_rpc_test.go b/internal/rpc/channels_dialogs_rpc_test.go index 33e0b224..6cfedf45 100644 --- a/internal/rpc/channels_dialogs_rpc_test.go +++ b/internal/rpc/channels_dialogs_rpc_test.go @@ -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, diff --git a/internal/rpc/channels_updates.go b/internal/rpc/channels_updates.go index 48aad29f..c7db7409 100644 --- a/internal/rpc/channels_updates.go +++ b/internal/rpc/channels_updates.go @@ -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 { diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go index eb99a6a0..51868c0f 100644 --- a/internal/rpc/convert_channels_core.go +++ b/internal/rpc/convert_channels_core.go @@ -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 { diff --git a/internal/rpc/linked_discussion_projection.go b/internal/rpc/linked_discussion_projection.go new file mode 100644 index 00000000..beda05c1 --- /dev/null +++ b/internal/rpc/linked_discussion_projection.go @@ -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 + } +} diff --git a/internal/rpc/messages_history.go b/internal/rpc/messages_history.go index 7dca9c94..804e92c5 100644 --- a/internal/rpc/messages_history.go +++ b/internal/rpc/messages_history.go @@ -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, diff --git a/internal/rpc/story_peer_projection.go b/internal/rpc/story_peer_projection.go index 4255ea04..8f3cfab2 100644 --- a/internal/rpc/story_peer_projection.go +++ b/internal/rpc/story_peer_projection.go @@ -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 // 进入 Users(Android putUsers 会覆盖 currentUser)。 out.Users = r.tgUsersForViewer(viewerUserID, discussion.Users) diff --git a/internal/store/memory/channel_core.go b/internal/store/memory/channel_core.go index d06352ab..99f52406 100644 --- a/internal/store/memory/channel_core.go +++ b/internal/store/memory/channel_core.go @@ -124,6 +124,38 @@ func (s *ChannelStore) GetChannel(_ context.Context, viewerUserID, channelID int }, nil } +// GetLinkedDiscussionChannel returns the discussion peer through an active +// membership in its source broadcast channel. It deliberately does not turn +// the viewer into a discussion-group member: callers need the Left projection +// so clients can show the comment entry and, when needed, the Join Group gate. +func (s *ChannelStore) GetLinkedDiscussionChannel(_ context.Context, viewerUserID, sourceChannelID int64) (domain.ChannelView, error) { + s.mu.RLock() + defer s.mu.RUnlock() + source, _, err := s.channelAndMemberLocked(viewerUserID, sourceChannelID) + if err != nil { + return domain.ChannelView{}, err + } + if !source.Broadcast || source.LinkedChatID == 0 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + linked, ok := s.channels[source.LinkedChatID] + if !ok || linked.Deleted || !linked.Megagroup || linked.Broadcast { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + self, guest, guestErr := s.linkedDiscussionGuestLocked(viewerUserID, linked) + if guestErr != nil { + return domain.ChannelView{}, guestErr + } + if !guest { + if member, ok := s.members[linked.ID][viewerUserID]; ok { + self = member + } else { + return domain.ChannelView{}, domain.ErrChannelPrivate + } + } + return domain.ChannelView{Channel: cloneChannel(linked), Self: self}, nil +} + // ResolveChannel 是 GetChannel 的轻量版:只做访问校验并返回 Channel+Self,跳过 dialog/boost。 // 与 postgres 实现语义一致(内存侧 dialog/boost 本就便宜,但保持接口行为对齐)。 func (s *ChannelStore) ResolveChannel(_ context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) { diff --git a/internal/store/memory/channel_helpers.go b/internal/store/memory/channel_helpers.go index 5e99d59f..515df5c9 100644 --- a/internal/store/memory/channel_helpers.go +++ b/internal/store/memory/channel_helpers.go @@ -359,6 +359,11 @@ func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.C if found && (existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages) { return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelUserBanned } + if guest, ok, guestErr := s.linkedDiscussionGuestLocked(userID, channel); guestErr != nil { + return domain.Channel{}, domain.ChannelMember{}, false, guestErr + } else if ok { + return channel, guest, true, nil + } if channel.Monoforum && channel.LinkedMonoforumID != 0 { parentMember, ok := s.members[channel.LinkedMonoforumID][userID] if ok && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) { diff --git a/internal/store/memory/channel_members.go b/internal/store/memory/channel_members.go index a77762ba..e63ce9fb 100644 --- a/internal/store/memory/channel_members.go +++ b/internal/store/memory/channel_members.go @@ -2,6 +2,7 @@ package memory import ( "context" + "errors" "sort" "strconv" "strings" @@ -11,14 +12,13 @@ import ( func (s *ChannelStore) GetParticipants(_ context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { s.mu.RLock() defer s.mu.RUnlock() - channel, err := s.channelForMemberLocked(viewerUserID, channelID) + channel, viewer, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, channelID) if err != nil { return domain.ChannelParticipantList{}, err } if limit <= 0 || limit > domain.MaxChannelParticipantsLimit { limit = domain.MaxChannelParticipantsLimit } - viewer := s.members[channelID][viewerUserID] // 广播频道订阅者列表仅管理员可枚举(与隐藏成员同一门控):admins filter 仍放行(徽章数据源)。 if channel.MembersListAdminOnly() && !isChannelAdmin(viewer) { switch filter.Kind { @@ -75,9 +75,14 @@ func (s *ChannelStore) GetParticipants(_ context.Context, viewerUserID, channelI func (s *ChannelStore) GetParticipant(_ context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) { s.mu.RLock() defer s.mu.RUnlock() - if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil { + channel, viewer, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, channelID) + if err != nil { return domain.ChannelMember{}, err } + if viewerUserID == participantUserID && viewer.Guest { + return viewer, nil + } + _ = channel member, ok := s.members[channelID][participantUserID] if !ok { return domain.ChannelMember{}, domain.ErrChannelPrivate @@ -824,7 +829,7 @@ func (s *ChannelStore) recordPublicJoinRequestLocked(channel domain.Channel, use func (s *ChannelStore) ListActiveChannelMemberIDs(_ context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) { s.mu.RLock() defer s.mu.RUnlock() - if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil { + if _, _, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, channelID); err != nil { return nil, err } return s.activeMemberIDsLocked(channelID, 0, limit), nil @@ -863,7 +868,7 @@ func (s *ChannelStore) FilterActiveChannelMemberIDs(_ context.Context, channelID func (s *ChannelStore) ListActiveChannelMembers(_ context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) { s.mu.RLock() defer s.mu.RUnlock() - channel, viewer, err := s.channelAndMemberLocked(viewerUserID, channelID) + channel, viewer, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, channelID) if err != nil { return domain.Channel{}, domain.ChannelMember{}, nil, err } @@ -905,6 +910,57 @@ func (s *ChannelStore) channelAndMemberLocked(userID, channelID int64) (domain.C return channel, member, nil } +func (s *ChannelStore) channelAndMemberOrLinkedGuestLocked(userID, channelID int64) (domain.Channel, domain.ChannelMember, error) { + channel, member, err := s.channelAndMemberLocked(userID, channelID) + if !errors.Is(err, domain.ErrChannelPrivate) { + return channel, member, err + } + target, ok := s.channels[channelID] + if !ok || target.Deleted { + return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelInvalid + } + guest, allowed, guestErr := s.linkedDiscussionGuestLocked(userID, target) + if guestErr != nil { + return domain.Channel{}, domain.ChannelMember{}, guestErr + } + if !allowed { + return domain.Channel{}, domain.ChannelMember{}, err + } + return target, guest, nil +} + +// linkedDiscussionGuestLocked authorizes a discussion-group view through the +// viewer's active membership in its bidirectionally linked broadcast. It never +// materializes a channel_members row. Explicit target bans always win. +func (s *ChannelStore) linkedDiscussionGuestLocked(userID int64, target domain.Channel) (domain.ChannelMember, bool, error) { + if target.Broadcast || !target.Megagroup || target.LinkedChatID == 0 { + return domain.ChannelMember{}, false, nil + } + if existing, ok := s.members[target.ID][userID]; ok { + if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages { + return domain.ChannelMember{}, false, domain.ErrChannelUserBanned + } + if existing.Status == domain.ChannelMemberActive { + return existing, false, nil + } + } + source, ok := s.channels[target.LinkedChatID] + if !ok || source.Deleted || !source.Broadcast || source.LinkedChatID != target.ID { + return domain.ChannelMember{}, false, nil + } + sourceMember, ok := s.members[source.ID][userID] + if !ok || sourceMember.Status != domain.ChannelMemberActive || sourceMember.BannedRights.ViewMessages { + return domain.ChannelMember{}, false, nil + } + return domain.ChannelMember{ + ChannelID: target.ID, + UserID: userID, + Status: domain.ChannelMemberLeft, + Role: domain.ChannelRoleMember, + Guest: true, + }, true, nil +} + func (s *ChannelStore) activeMemberIDsLocked(channelID, excludeUserID int64, limit int) []int64 { members := s.members[channelID] if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { diff --git a/internal/store/memory/channel_message_history.go b/internal/store/memory/channel_message_history.go index d386281e..f2199b9d 100644 --- a/internal/store/memory/channel_message_history.go +++ b/internal/store/memory/channel_message_history.go @@ -2,6 +2,7 @@ package memory import ( "context" + "errors" "sort" "strings" "telesrv/internal/domain" @@ -413,6 +414,53 @@ func (s *ChannelStore) GetDiscussionMessage(_ context.Context, viewerUserID, cha return result, nil } +// ResolveDiscussionReadTarget returns the minimal linked-thread mapping and +// current read boundary without building reply/reaction aggregates. +func (s *ChannelStore) ResolveDiscussionReadTarget(_ context.Context, viewerUserID, sourceChannelID int64, sourceMessageID, readMaxID int) (domain.ChannelDiscussionReadTarget, error) { + s.mu.RLock() + defer s.mu.RUnlock() + source, sourceMember, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, sourceChannelID) + if err != nil { + return domain.ChannelDiscussionReadTarget{}, err + } + msg, ok := s.findMessageLocked(sourceChannelID, sourceMessageID) + if !ok || msg.Deleted || msg.ID <= sourceMember.AvailableMinID { + return domain.ChannelDiscussionReadTarget{}, domain.ErrMessageIDInvalid + } + targetChannelID, rootID := sourceChannelID, sourceMessageID + if source.Broadcast && msg.Discussion != nil { + if msg.Discussion.ChannelID == 0 || msg.Discussion.MessageID == 0 { + return domain.ChannelDiscussionReadTarget{}, domain.ErrMessageIDInvalid + } + linked, exists := s.channels[msg.Discussion.ChannelID] + root, rootExists := s.findMessageLocked(msg.Discussion.ChannelID, msg.Discussion.MessageID) + if !exists || linked.Deleted || !linked.Megagroup || linked.Broadcast || !rootExists || root.Deleted { + return domain.ChannelDiscussionReadTarget{}, domain.ErrMessageIDInvalid + } + targetChannelID, rootID = linked.ID, root.ID + } + targetMember := sourceMember + if targetChannelID != sourceChannelID { + _, targetMember, err = s.channelAndMemberLocked(viewerUserID, targetChannelID) + if errors.Is(err, domain.ErrChannelPrivate) { + targetMember, _, err = s.linkedDiscussionGuestLocked(viewerUserID, s.channels[targetChannelID]) + } + } + if err != nil { + return domain.ChannelDiscussionReadTarget{}, err + } + effectiveMaxID := readMaxID + if target := s.channels[targetChannelID]; effectiveMaxID <= 0 || effectiveMaxID > target.TopMessageID { + effectiveMaxID = target.TopMessageID + } + return domain.ChannelDiscussionReadTarget{ + ChannelID: targetChannelID, + RootID: rootID, + AlreadyRead: effectiveMaxID <= targetMember.ReadInboxMaxID && !targetMember.UnreadMark, + Guest: targetMember.Guest, + }, nil +} + func (s *ChannelStore) findMessageLocked(channelID int64, id int) (domain.ChannelMessage, bool) { for _, msg := range s.messages[channelID] { if msg.ID == id { diff --git a/internal/store/memory/channel_message_send.go b/internal/store/memory/channel_message_send.go index 41d64b2e..263408d7 100644 --- a/internal/store/memory/channel_message_send.go +++ b/internal/store/memory/channel_message_send.go @@ -2,6 +2,7 @@ package memory import ( "context" + "errors" "fmt" "strings" "telesrv/internal/domain" @@ -37,11 +38,22 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan return replay, replayErr } } - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if errors.Is(err, domain.ErrChannelPrivate) { + if candidate, ok := s.channels[req.ChannelID]; ok && !candidate.Deleted { + var guest bool + member, guest, err = s.linkedDiscussionGuestLocked(req.UserID, candidate) + if guest { + channel = candidate + } + } + } if err != nil { return domain.SendChannelMessageResult{}, err } - member := s.members[req.ChannelID][req.UserID] + if member.Guest && channel.JoinToSend { + return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden + } if req.Date == 0 { req.Date = int(time.Now().Unix()) } @@ -198,8 +210,10 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan channel.TopMessageID = msg.ID channel.Pts = pts s.channels[req.ChannelID] = channel - member.SlowmodeLastSendDate = req.Date - s.members[req.ChannelID][req.UserID] = member + if !member.Guest { + member.SlowmodeLastSendDate = req.Date + s.members[req.ChannelID][req.UserID] = member + } for userID, member := range s.members[req.ChannelID] { if member.Status == domain.ChannelMemberActive { if _, skip := skipDelivery[userID]; skip && userID != req.UserID { diff --git a/internal/store/memory/channel_topic_read.go b/internal/store/memory/channel_topic_read.go index 310c1ede..f522324d 100644 --- a/internal/store/memory/channel_topic_read.go +++ b/internal/store/memory/channel_topic_read.go @@ -109,13 +109,16 @@ func (s *ChannelStore) ReadChannelTopicHistory(_ context.Context, req domain.Rea } s.mu.Lock() defer s.mu.Unlock() - channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + channel, member, err := s.channelAndMemberOrLinkedGuestLocked(req.UserID, req.ChannelID) if err != nil { return domain.ReadChannelTopicHistoryResult{}, err } if !channel.Forum { return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelForumMissing } + if member.Guest { + return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelPrivate + } topMax := s.channelTopicTopMessageIDLocked(req.ChannelID, req.TopicID, member.AvailableMinID) maxID := req.MaxID if maxID <= 0 || maxID > topMax { diff --git a/internal/store/memory/channel_topics.go b/internal/store/memory/channel_topics.go index 5e95b29b..d0a1baf1 100644 --- a/internal/store/memory/channel_topics.go +++ b/internal/store/memory/channel_topics.go @@ -499,7 +499,7 @@ 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.channelAndMemberLocked(viewerUserID, filter.ChannelID) + source, member, err := s.channelAndMemberOrLinkedGuestLocked(viewerUserID, filter.ChannelID) if err != nil { return domain.ChannelHistory{}, err } diff --git a/internal/store/postgres/channel_core.go b/internal/store/postgres/channel_core.go index 565d54fd..4814fea8 100644 --- a/internal/store/postgres/channel_core.go +++ b/internal/store/postgres/channel_core.go @@ -252,6 +252,38 @@ func (s *ChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID i return domain.ChannelView{Channel: channel, Self: member, Dialog: dialog, SelfBoostsApplied: selfBoosts, ExportedInvite: exportedInvite}, nil } +// GetLinkedDiscussionChannel projects a private discussion peer through the +// viewer's active membership in the source broadcast channel. This is a +// peer-discovery boundary only; it never creates discussion-group membership. +func (s *ChannelStore) GetLinkedDiscussionChannel(ctx context.Context, viewerUserID, sourceChannelID int64) (domain.ChannelView, error) { + source, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, sourceChannelID) + if err != nil { + return domain.ChannelView{}, err + } + if !source.Broadcast || source.LinkedChatID == 0 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + linked, err := s.channelByID(ctx, s.db, source.LinkedChatID) + if err != nil { + return domain.ChannelView{}, err + } + if !linked.Megagroup || linked.Broadcast { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + self, guest, guestErr := s.getLinkedDiscussionGuest(ctx, s.db, viewerUserID, linked) + if guestErr != nil { + return domain.ChannelView{}, guestErr + } + if !guest { + if member, memberErr := s.getChannelMember(ctx, s.db, linked.ID, viewerUserID); memberErr == nil { + self = member + } else { + return domain.ChannelView{}, memberErr + } + } + return domain.ChannelView{Channel: linked, Self: self}, nil +} + // ResolveChannel 是 GetChannel 的轻量版:只做访问校验并返回 Channel(含 access_hash)+Self, // 跳过 dialog top message / 读态 / boost 求和这 3 条额外 PG 查询。供 inputPeerFor 等只需 // access_hash / 频道标志的纯解析路径用——它们此前为拿一个 access_hash 付了完整 4 查询投影。 diff --git a/internal/store/postgres/channel_discussion_read_target_integration_test.go b/internal/store/postgres/channel_discussion_read_target_integration_test.go new file mode 100644 index 00000000..2d082a65 --- /dev/null +++ b/internal/store/postgres/channel_discussion_read_target_integration_test.go @@ -0,0 +1,138 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestResolveDiscussionReadTargetPostgres locks the one-query readDiscussion +// projection against the real schema. It verifies linked-root mapping and the +// durable idempotent boundary without invoking the full discussion aggregate. +func TestResolveDiscussionReadTargetPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 991, + Phone: "+1991" + suffix + "01", + FirstName: "DiscussionReadOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + subscriber, err := users.Create(ctx, domain.User{ + AccessHash: 992, + Phone: "+1992" + suffix + "02", + FirstName: "DiscussionGuest", + }) + if err != nil { + t.Fatalf("create subscriber: %v", err) + } + channels := NewChannelStore(pool, + WithChannelRowCache(NewChannelRowCache(32)), + WithChannelMemberCache(NewChannelMemberCache(64))) + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) > 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, subscriber.ID}) + }) + broadcast, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Discussion Read Source " + suffix, + Broadcast: true, + Date: 1700002900, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + channelIDs = append(channelIDs, broadcast.Channel.ID) + group, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Discussion Read Group " + suffix, + Megagroup: true, + Date: 1700002901, + }) + if err != nil { + t.Fatalf("create group: %v", err) + } + channelIDs = append(channelIDs, group.Channel.ID) + if _, err := channels.SetDiscussionGroup(ctx, owner.ID, broadcast.Channel.ID, group.Channel.ID); err != nil { + t.Fatalf("set discussion group: %v", err) + } + if _, err := channels.InviteToChannel(ctx, broadcast.Channel.ID, owner.ID, []int64{subscriber.ID}, 1700002902); err != nil { + t.Fatalf("invite broadcast subscriber: %v", err) + } + post, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: broadcast.Channel.ID, + RandomID: 9912901, + Message: "discussion read target", + Date: 1700002902, + }) + if err != nil { + t.Fatalf("send post: %v", err) + } + if post.Discussion == nil { + t.Fatal("send post discussion result is nil") + } + rootID := post.Discussion.Message.ID + guestView, err := channels.GetChannel(ctx, subscriber.ID, group.Channel.ID) + if err != nil || !guestView.Self.Guest || guestView.Self.Status != domain.ChannelMemberLeft { + t.Fatalf("linked guest view = %+v err %v", guestView.Self, err) + } + guestTarget, err := channels.ResolveDiscussionReadTarget(ctx, subscriber.ID, broadcast.Channel.ID, post.Message.ID, rootID) + if err != nil || !guestTarget.Guest { + t.Fatalf("linked guest read target = %+v err %v", guestTarget, err) + } + directGuestTarget, err := channels.ResolveDiscussionReadTarget(ctx, subscriber.ID, group.Channel.ID, rootID, rootID) + if err != nil || !directGuestTarget.Guest || directGuestTarget.ChannelID != group.Channel.ID { + t.Fatalf("direct linked-group guest read target = %+v err %v", directGuestTarget, err) + } + if _, err := channels.ListActiveChannelBotMemberIDs(ctx, subscriber.ID, group.Channel.ID, 20); err != nil { + t.Fatalf("linked guest bot-delivery preflight: %v", err) + } + if _, err := channels.ListActiveChannelBotMembers(ctx, subscriber.ID, group.Channel.ID, 0, 20); err != nil { + t.Fatalf("linked guest bot participants: %v", err) + } + if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: subscriber.ID, ChannelID: group.Channel.ID, RandomID: 9912902, + Message: "guest comment", Date: 1700002903, + ReplyTo: &domain.MessageReply{MessageID: rootID, TopMessageID: rootID}, + }); err != nil { + t.Fatalf("send linked guest comment: %v", err) + } + var persistedGuest bool + if err := pool.QueryRow(ctx, `SELECT EXISTS ( +SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2 +)`, group.Channel.ID, subscriber.ID).Scan(&persistedGuest); err != nil { + t.Fatalf("check guest member row: %v", err) + } + if persistedGuest { + t.Fatal("linked guest send persisted a discussion-group member row") + } + target, err := channels.ResolveDiscussionReadTarget(ctx, owner.ID, broadcast.Channel.ID, post.Message.ID, rootID) + if err != nil { + t.Fatalf("resolve before read: %v", err) + } + if target.ChannelID != group.Channel.ID || target.RootID != rootID || target.AlreadyRead { + t.Fatalf("target before read = %+v, want linked unread root", target) + } + read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: owner.ID, + ChannelID: group.Channel.ID, + MaxID: rootID, + Date: 1700002903, + }) + if err != nil || !read.Changed { + t.Fatalf("read linked group = %+v err %v, want changed", read, err) + } + target, err = channels.ResolveDiscussionReadTarget(ctx, owner.ID, broadcast.Channel.ID, post.Message.ID, rootID) + if err != nil || !target.AlreadyRead { + t.Fatalf("resolve after read = %+v err %v, want already read", target, err) + } +} diff --git a/internal/store/postgres/channel_helpers.go b/internal/store/postgres/channel_helpers.go index e9efa277..338add5b 100644 --- a/internal/store/postgres/channel_helpers.go +++ b/internal/store/postgres/channel_helpers.go @@ -371,6 +371,11 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX, if err != nil { return domain.Channel{}, domain.ChannelMember{}, false, err } + if guest, ok, guestErr := s.getLinkedDiscussionGuest(ctx, db, viewerUserID, ch); guestErr != nil { + return domain.Channel{}, domain.ChannelMember{}, false, guestErr + } else if ok { + return ch, guest, true, nil + } if member, _, ok, err := s.monoforumAdminPreview(ctx, db, viewerUserID, ch); err != nil { return domain.Channel{}, domain.ChannelMember{}, false, err } else if ok { diff --git a/internal/store/postgres/channel_member_helpers.go b/internal/store/postgres/channel_member_helpers.go index c3e82c9e..9abf479e 100644 --- a/internal/store/postgres/channel_member_helpers.go +++ b/internal/store/postgres/channel_member_helpers.go @@ -25,6 +25,77 @@ func (s *ChannelStore) getChannelForMember(ctx context.Context, db sqlcgen.DBTX, return ch, member, nil } +func (s *ChannelStore) getChannelForMemberOrLinkedGuest(ctx context.Context, db sqlcgen.DBTX, viewerUserID, channelID int64) (domain.Channel, domain.ChannelMember, error) { + channel, member, err := s.getChannelForMember(ctx, db, viewerUserID, channelID) + if !errors.Is(err, domain.ErrChannelPrivate) { + return channel, member, err + } + target, targetErr := s.channelByID(ctx, db, channelID) + if targetErr != nil { + return domain.Channel{}, domain.ChannelMember{}, targetErr + } + guest, allowed, guestErr := s.getLinkedDiscussionGuest(ctx, db, viewerUserID, target) + if guestErr != nil { + return domain.Channel{}, domain.ChannelMember{}, guestErr + } + if !allowed { + return domain.Channel{}, domain.ChannelMember{}, err + } + return target, guest, nil +} + +// getLinkedDiscussionGuest authorizes a private discussion group through an +// active membership in its bidirectionally linked broadcast. The returned +// member is computed only and must never be persisted. An explicit target ban +// or kick takes precedence over the source-channel membership. +func (s *ChannelStore) getLinkedDiscussionGuest(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, target domain.Channel) (domain.ChannelMember, bool, error) { + if target.Broadcast || !target.Megagroup || target.LinkedChatID == 0 { + return domain.ChannelMember{}, false, nil + } + existing, err := s.getChannelMember(ctx, db, target.ID, viewerUserID) + if err == nil { + if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages { + return domain.ChannelMember{}, false, domain.ErrChannelUserBanned + } + if existing.Status == domain.ChannelMemberActive { + return existing, false, nil + } + } else if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.ChannelMember{}, false, err + } + source, err := s.channelByID(ctx, db, target.LinkedChatID) + if err != nil { + if errors.Is(err, domain.ErrChannelInvalid) { + return domain.ChannelMember{}, false, nil + } + return domain.ChannelMember{}, false, err + } + if !source.Broadcast || source.LinkedChatID != target.ID { + return domain.ChannelMember{}, false, nil + } + sourceMember, err := s.getChannelMember(ctx, db, source.ID, viewerUserID) + if err != nil { + if errors.Is(err, domain.ErrChannelPrivate) { + return domain.ChannelMember{}, false, nil + } + return domain.ChannelMember{}, false, err + } + if err := validateChannelMemberVisible(sourceMember); err != nil { + return domain.ChannelMember{}, false, err + } + guest := domain.ChannelMember{ + ChannelID: target.ID, + UserID: viewerUserID, + Status: domain.ChannelMemberLeft, + Role: domain.ChannelRoleMember, + Guest: true, + } + if s.memberCacheActive(db) { + s.memberCache.put(guest) + } + return guest, true, nil +} + func (s *ChannelStore) getPublicPreviewMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, ch domain.Channel) (domain.ChannelMember, error) { member, err := s.getChannelMember(ctx, db, ch.ID, viewerUserID) if err != nil { diff --git a/internal/store/postgres/channel_member_list.go b/internal/store/postgres/channel_member_list.go index 9e59763c..1e0a60bc 100644 --- a/internal/store/postgres/channel_member_list.go +++ b/internal/store/postgres/channel_member_list.go @@ -12,7 +12,7 @@ import ( ) func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { - channel, viewer, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + channel, viewer, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID) if err != nil { return domain.ChannelParticipantList{}, err } @@ -170,9 +170,13 @@ WHERE channel_id = $1 } func (s *ChannelStore) GetParticipant(ctx context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) { - if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil { + _, viewer, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID) + if err != nil { return domain.ChannelMember{}, err } + if viewerUserID == participantUserID && viewer.Guest { + return viewer, nil + } return s.getChannelMember(ctx, s.db, channelID, participantUserID) } @@ -200,7 +204,7 @@ func (s *ChannelStore) ListActiveChannelMemberIDs(ctx context.Context, viewerUse } func (s *ChannelStore) ListActiveChannelMembers(ctx context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) { - channel, viewer, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + channel, viewer, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID) if err != nil { return domain.Channel{}, domain.ChannelMember{}, nil, err } @@ -233,7 +237,7 @@ LIMIT $2`, channelID, limit) } func (s *ChannelStore) ListActiveChannelBotMembers(ctx context.Context, viewerUserID, channelID int64, offset, limit int) (domain.ChannelParticipantList, error) { - channel, viewer, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + channel, viewer, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID) if err != nil { return domain.ChannelParticipantList{}, err } @@ -279,7 +283,7 @@ OFFSET $2 LIMIT $3`, channelID, offset, limit) } func (s *ChannelStore) ListActiveChannelBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) { - if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil { + if _, _, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID); err != nil { return nil, err } if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { diff --git a/internal/store/postgres/channel_message_history.go b/internal/store/postgres/channel_message_history.go index 4bccdff2..99f8fd00 100644 --- a/internal/store/postgres/channel_message_history.go +++ b/internal/store/postgres/channel_message_history.go @@ -565,6 +565,165 @@ func (s *ChannelStore) GetDiscussionMessage(ctx context.Context, viewerUserID, c return result, nil } +// ResolveDiscussionReadTarget resolves source post -> linked discussion root +// and checks the durable read boundary in one indexed query. It is the hot path +// for messages.readDiscussion and must not load reply stats, reactions or peer +// payloads used by messages.getDiscussionMessage. +func (s *ChannelStore) ResolveDiscussionReadTarget(ctx context.Context, userID, sourceChannelID int64, sourceMessageID, readMaxID int) (domain.ChannelDiscussionReadTarget, error) { + var out domain.ChannelDiscussionReadTarget + var readInboxMaxID int + var unreadMark bool + var targetVisible bool + var targetGuest bool + var targetTopMessageID int + err := s.db.QueryRow(ctx, ` +WITH source AS ( + SELECT c.broadcast, + m.discussion_channel_id, + m.discussion_message_id, + ( + c.megagroup + AND NOT c.broadcast + AND c.linked_chat_id <> 0 + AND (viewer.user_id IS NULL OR viewer.status = 'left') + AND NOT COALESCE((viewer.banned_rights->>'ViewMessages')::boolean, false) + AND EXISTS ( + SELECT 1 + FROM channels parent + JOIN channel_members parent_member + ON parent_member.channel_id = parent.id + AND parent_member.user_id = $1 + AND parent_member.status = 'active' + AND NOT COALESCE((parent_member.banned_rights->>'ViewMessages')::boolean, false) + WHERE parent.id = c.linked_chat_id + AND parent.broadcast + AND NOT parent.deleted + AND parent.linked_chat_id = c.id + ) + ) AS linked_guest + FROM channels c + LEFT JOIN channel_members viewer + ON viewer.channel_id = c.id + AND viewer.user_id = $1 + JOIN channel_messages m + ON m.channel_id = c.id + AND m.id = $3 + AND NOT m.deleted + AND m.id > COALESCE(viewer.available_min_id, 0) + WHERE c.id = $2 AND NOT c.deleted + AND ( + ( + viewer.status = 'active' + AND NOT COALESCE((viewer.banned_rights->>'ViewMessages')::boolean, false) + ) + OR ( + c.megagroup + AND NOT c.broadcast + AND c.linked_chat_id <> 0 + AND (viewer.user_id IS NULL OR viewer.status = 'left') + AND NOT COALESCE((viewer.banned_rights->>'ViewMessages')::boolean, false) + AND EXISTS ( + SELECT 1 + FROM channels parent + JOIN channel_members parent_member + ON parent_member.channel_id = parent.id + AND parent_member.user_id = $1 + AND parent_member.status = 'active' + AND NOT COALESCE((parent_member.banned_rights->>'ViewMessages')::boolean, false) + WHERE parent.id = c.linked_chat_id + AND parent.broadcast + AND NOT parent.deleted + AND parent.linked_chat_id = c.id + ) + ) + ) +), target AS ( + SELECT CASE + WHEN broadcast AND discussion_channel_id <> 0 AND discussion_message_id <> 0 + THEN discussion_channel_id + ELSE $2 + END AS channel_id, + CASE + WHEN broadcast AND discussion_channel_id <> 0 AND discussion_message_id <> 0 + THEN discussion_message_id + ELSE $3 + END AS root_id, + broadcast, + linked_guest, + discussion_channel_id, + discussion_message_id + FROM source +) +SELECT target.channel_id, target.root_id, + target_channel.top_message_id, + COALESCE(target_member.read_inbox_max_id, 0), + COALESCE(target_member.unread_mark, false), + COALESCE( + target_member.status = 'active' + AND NOT COALESCE((target_member.banned_rights->>'ViewMessages')::boolean, false), + false + ) OR target.linked_guest OR ( + target.channel_id <> $2 + AND target.broadcast + AND target_channel.linked_chat_id = $2 + AND ( + target_member.user_id IS NULL + OR target_member.status = 'left' + ) + ) AS target_visible, + ( + target.linked_guest OR ( + target.channel_id <> $2 + AND target.broadcast + AND target_channel.linked_chat_id = $2 + AND ( + target_member.user_id IS NULL + OR target_member.status = 'left' + ) + ) + ) AS target_guest +FROM target +JOIN channels target_channel + ON target_channel.id = target.channel_id + AND NOT target_channel.deleted +LEFT JOIN channel_members target_member + ON target_member.channel_id = target.channel_id + AND target_member.user_id = $1 +WHERE ( + NOT target.broadcast + OR (target.discussion_channel_id = 0 AND target.discussion_message_id = 0) + OR ( + target.discussion_channel_id <> 0 + AND target.discussion_message_id <> 0 + AND target_channel.megagroup + AND NOT target_channel.broadcast + AND EXISTS ( + SELECT 1 + FROM channel_messages root + WHERE root.channel_id = target.channel_id + AND root.id = target.root_id + AND NOT root.deleted + ) + ) +)`, userID, sourceChannelID, sourceMessageID).Scan(&out.ChannelID, &out.RootID, &targetTopMessageID, &readInboxMaxID, &unreadMark, &targetVisible, &targetGuest) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelDiscussionReadTarget{}, domain.ErrMessageIDInvalid + } + if err != nil { + return domain.ChannelDiscussionReadTarget{}, fmt.Errorf("resolve discussion read target: %w", err) + } + if !targetVisible { + return domain.ChannelDiscussionReadTarget{}, domain.ErrChannelPrivate + } + effectiveMaxID := readMaxID + if effectiveMaxID <= 0 || effectiveMaxID > targetTopMessageID { + effectiveMaxID = targetTopMessageID + } + out.AlreadyRead = effectiveMaxID <= readInboxMaxID && !unreadMark + out.Guest = targetGuest + return out, nil +} + func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { channel, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { diff --git a/internal/store/postgres/channel_message_send.go b/internal/store/postgres/channel_message_send.go index 6cedece8..7563e2bf 100644 --- a/internal/store/postgres/channel_message_send.go +++ b/internal/store/postgres/channel_message_send.go @@ -64,9 +64,23 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if errors.Is(err, domain.ErrChannelPrivate) { + if candidate, candidateErr := s.channelByID(ctx, tx, req.ChannelID); candidateErr == nil { + var guest bool + member, guest, err = s.getLinkedDiscussionGuest(ctx, tx, req.UserID, candidate) + if guest { + channel = candidate + } + } else { + err = candidateErr + } + } if err != nil { return domain.SendChannelMessageResult{}, err } + if member.Guest && channel.JoinToSend { + return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden + } fromBoostsApplied := 0 if channel.Megagroup { fromBoostsApplied, err = countActiveUserBoostsForPeer(ctx, tx, req.UserID, domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, req.Date) diff --git a/internal/store/postgres/channel_topic_read.go b/internal/store/postgres/channel_topic_read.go index 370b58b2..f65a031c 100644 --- a/internal/store/postgres/channel_topic_read.go +++ b/internal/store/postgres/channel_topic_read.go @@ -108,13 +108,16 @@ func (s *ChannelStore) ReadChannelTopicHistory(ctx context.Context, req domain.R if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelInvalid } - channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + channel, member, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ReadChannelTopicHistoryResult{}, err } if !channel.Forum { return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelForumMissing } + if member.Guest { + return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelPrivate + } topMax, err := s.channelTopicTopMessageID(ctx, req.ChannelID, req.TopicID, member.AvailableMinID) if err != nil { return domain.ReadChannelTopicHistoryResult{}, err diff --git a/internal/store/postgres/channel_topics.go b/internal/store/postgres/channel_topics.go index 705de58f..181dd5e2 100644 --- a/internal/store/postgres/channel_topics.go +++ b/internal/store/postgres/channel_topics.go @@ -586,7 +586,7 @@ 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.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) + source, member, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, filter.ChannelID) if err != nil { return domain.ChannelHistory{}, err }