fix: sync linked discussion guest support
This commit is contained in:
parent
5f7c0b9804
commit
9f73dc20da
27 changed files with 1004 additions and 30 deletions
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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 基础,用于兼容和后续功能扩展。 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
65
internal/rpc/linked_discussion_projection.go
Normal file
65
internal/rpc/linked_discussion_projection.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 查询投影。
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue