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

@ -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. |

View file

@ -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 基础,用于兼容和后续功能扩展。 |

View file

@ -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.

View file

@ -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

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)

View file

@ -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) {

View file

@ -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) {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}

View file

@ -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 查询投影。

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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
}