diff --git a/internal/domain/channel.go b/internal/domain/channel.go index 92d7f1f3..ff0b188a 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -1627,6 +1627,7 @@ type SendMonoforumMessageRequest struct { Entities []MessageEntity Media *MessageMedia ReplyTo *MessageReply + Forward *MessageForward Silent bool NoForwards bool SuggestedPost *SuggestedPost diff --git a/internal/rpc/messages_forward.go b/internal/rpc/messages_forward.go index a22a7caf..fb2adbef 100644 --- a/internal/rpc/messages_forward.go +++ b/internal/rpc/messages_forward.go @@ -64,6 +64,39 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages return nil, internalErr() } } + suggestedInput, hasSuggestedPost := req.GetSuggestedPost() + var mono domain.Channel + var monoforum, monoforumAdmin bool + if toPeer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + mono, monoforumAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, toPeer.ID) + switch { + case err == nil: + monoforum = true + case !errors.Is(err, domain.ErrChannelInvalid): + return nil, internalErr() + } + } + if hasSuggestedPost && !monoforum { + return nil, suggestedPostPeerInvalidErr() + } + if monoforum { + suggestedPost, err := domainSuggestedPost(suggestedInput, hasSuggestedPost) + if err != nil { + return nil, err + } + return r.forwardMessagesToMonoforum( + ctx, + userID, + toPeer, + mono, + monoforumAdmin, + req, + idempotencyFingerprints, + suggestedPost, + topMsgID, + topMsgIDSet, + ) + } immediate := req.ScheduleDate == 0 || scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) replays := make([]outgoingReplayLookup, len(req.ID)) absentIndexes := make([]int, 0, len(req.ID)) @@ -307,6 +340,132 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages return nil, peerIDInvalidErr() } +func (r *Router) forwardMessagesToMonoforum( + ctx context.Context, + userID int64, + toPeer domain.Peer, + mono domain.Channel, + monoforumAdmin bool, + req *tg.MessagesForwardMessagesRequest, + idempotencyFingerprints [][]byte, + suggestedPost *domain.SuggestedPost, + topMsgID int, + topMsgIDSet bool, +) (tg.UpdatesClass, error) { + if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) { + return nil, scheduleDateInvalidErr() + } + savedPeer, err := r.monoforumSavedPeerForSender(userID, monoforumAdmin, req.ReplyTo) + if err != nil { + return nil, err + } + replyTo, err := r.monoforumMessageReplyFromInput(ctx, userID, toPeer, req.ReplyTo) + if err != nil { + return nil, err + } + // DrKLO currently mirrors the replied suggestion into top_msg_id as well as reply_to. + // Monoforum has no forum root: accept only the redundant equal value and never persist it + // as a topic root. + if topMsgIDSet && topMsgID != 0 && (replyTo == nil || replyTo.MessageID != topMsgID) { + return nil, replyMessageIDInvalidErr() + } + + replays := make([]outgoingReplayLookup, len(req.ID)) + absentIndexes := make([]int, 0, len(req.ID)) + for i := range req.ID { + replay, err := r.lookupChannelSendReplay( + ctx, + userID, + toPeer.ID, + savedPeer, + req.RandomID[i], + idempotencyFingerprints[i], + ) + if err != nil { + return nil, err + } + replays[i] = replay + if !replay.found { + absentIndexes = append(absentIndexes, i) + } + } + if len(absentIndexes) == 0 { + results := make([]tg.UpdatesClass, 0, len(replays)) + for _, replay := range replays { + results = append(results, r.monoforumSendUpdates(ctx, userID, mono, savedPeer, replay.channel)) + } + return combineSendUpdates(results), nil + } + if err := r.checkSendRateLimit(ctx, userID, len(absentIndexes)); err != nil { + return nil, err + } + checkedPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ToPeer) + if err != nil { + return nil, err + } + if checkedPeer != toPeer { + return nil, peerIDInvalidErr() + } + + absentIDs := make([]int, len(absentIndexes)) + absentRandomIDs := make([]int64, len(absentIndexes)) + for i, originalIndex := range absentIndexes { + absentIDs[i] = req.ID[originalIndex] + absentRandomIDs[i] = req.RandomID[originalIndex] + } + fromPeer, preloadedSources, err := r.forwardFromPeerAndSources(ctx, userID, req.FromPeer, absentIDs, absentRandomIDs) + if err != nil { + return nil, err + } + if fromPeer.Type == domain.PeerTypeUser && fromPeer.ID != userID && r.deps.Users != nil { + if _, found, err := r.deps.Users.ByID(ctx, userID, fromPeer.ID); err != nil { + return nil, internalErr() + } else if !found { + return nil, peerIDInvalidErr() + } + } + absentSources, err := r.forwardSourcesForRequest(ctx, userID, fromPeer, absentIDs, preloadedSources) + if err != nil { + return nil, messageForwardErr(err) + } + sources := make([]forwardSource, len(req.ID)) + for i, originalIndex := range absentIndexes { + sources[originalIndex] = absentSources[i] + } + + results := make([]tg.UpdatesClass, 0, len(req.ID)) + for i, source := range sources { + if replays[i].found { + results = append(results, r.monoforumSendUpdates(ctx, userID, mono, savedPeer, replays[i].channel)) + continue + } + forward := source.forward + if req.DropAuthor { + forward = nil + } + updates, err := r.sendMonoforumMessage(ctx, userID, checkedPeer, mono, monoforumAdmin, domain.SendMonoforumMessageRequest{ + SavedPeer: savedPeer, + RandomID: req.RandomID[i], + IdempotencyFingerprint: idempotencyFingerprints[i], + IdempotencyPreflighted: replays[i].checked, + Message: source.body, + Entities: source.entities, + Media: source.media, + ReplyTo: replyTo, + Forward: forward, + Silent: req.Silent, + NoForwards: req.Noforwards, + SuggestedPost: suggestedPost, + AllowPaidStars: req.AllowPaidStars, + }) + if err != nil { + return nil, err + } + results = append(results, updates) + } + return combineSendUpdates(results), nil +} + func normalizeForwardMessageVectors(ids []int, randomIDs []int64) ([]int, []int64, bool) { if len(ids) == 0 || len(randomIDs) == 0 { return nil, nil, false diff --git a/internal/rpc/messages_helpers.go b/internal/rpc/messages_helpers.go index e7f46406..6e7d6775 100644 --- a/internal/rpc/messages_helpers.go +++ b/internal/rpc/messages_helpers.go @@ -414,8 +414,6 @@ func forwardMessagesUnsupportedOptionErr(req *tg.MessagesForwardMessagesRequest) return starsAmountInvalidErr() case req.AllowPaidFloodskip: return paymentUnsupportedErr() - case !req.SuggestedPost.Zero(): - return suggestedPostPeerInvalidErr() default: return nil } diff --git a/internal/rpc/messages_monoforum_rpc_test.go b/internal/rpc/messages_monoforum_rpc_test.go index 1f741c03..7cf55646 100644 --- a/internal/rpc/messages_monoforum_rpc_test.go +++ b/internal/rpc/messages_monoforum_rpc_test.go @@ -467,3 +467,287 @@ func TestMonoforumSendMessageWritePath(t *testing.T) { t.Fatalf("suggested_post schedule = %d/%v, want 1700100000/true", scheduleDate, ok) } } + +// TestMonoforumForwardSuggestedPostAndReactionWritePath 回归 DrKLO 的三条真实路径: +// Add Offer 用 forwardMessages+suggested_post 新建建议,Edit Price/Edit Time 再回复原建议; +// get/send reaction 则必须允许无 member 行的订阅者访问自己的 saved_peer,且不能按猜测 id +// 跨到另一订阅者的子会话。 +func TestMonoforumForwardSuggestedPostAndReactionWritePath(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550004001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + sub, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550004002", FirstName: "Sub"}) + if err != nil { + t.Fatalf("create sub: %v", err) + } + other, err := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "15550004003", FirstName: "Other"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + }, zaptest.NewLogger(t), clock.System) + + created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "DM Suggested", Broadcast: true, Date: 2000}) + if err != nil { + t.Fatalf("create channel: %v", err) + } + enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true) + if err != nil { + t.Fatalf("enable DM: %v", err) + } + monoID := enabled.Channel.LinkedMonoforumID + mono, err := channelStore.GetChannelByID(ctx, monoID) + if err != nil { + t.Fatalf("get monoforum: %v", err) + } + monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash} + subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID} + original, err := channelStore.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, + RandomID: 7001, Message: "please publish this", Date: 2001, + }) + if err != nil { + t.Fatalf("seed subscriber message: %v", err) + } + otherMessage, err := channelStore.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: monoID, SenderUserID: other.ID, + SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}, + RandomID: 7002, Message: "another subscriber", Date: 2002, + }) + if err != nil { + t.Fatalf("seed other subscriber message: %v", err) + } + + // Android 打开 reaction 状态时会先发 getMessagesReactions。订阅者没有 channel_members + // 行,但自己的 saved_peer 消息必须正常返回,并携带 saved_peer_id 供客户端归组。 + reactionState, err := r.onMessagesGetMessagesReactions(WithUserID(ctx, sub.ID), &tg.MessagesGetMessagesReactionsRequest{ + Peer: monoInput, + ID: []int{original.Message.ID}, + }) + if err != nil { + t.Fatalf("subscriber getMessagesReactions(monoforum): %v", err) + } + reactionUpdates, ok := reactionState.(*tg.Updates) + if !ok || len(reactionUpdates.Updates) != 1 { + t.Fatalf("getMessagesReactions = %#v, want one update", reactionState) + } + reactionUpdate, ok := reactionUpdates.Updates[0].(*tg.UpdateMessageReactions) + if !ok { + t.Fatalf("getMessagesReactions update = %T, want UpdateMessageReactions", reactionUpdates.Updates[0]) + } + savedPeer, ok := reactionUpdate.GetSavedPeerID() + if !ok { + t.Fatalf("getMessagesReactions update missing saved_peer_id") + } + if peer, ok := savedPeer.(*tg.PeerUser); !ok || peer.UserID != sub.ID { + t.Fatalf("reaction saved_peer_id = %#v, want subscriber %d", savedPeer, sub.ID) + } + sendReaction := &tg.MessagesSendReactionRequest{Peer: monoInput, MsgID: original.Message.ID} + sendReaction.SetReaction([]tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}) + sentReaction, err := r.onMessagesSendReaction(WithUserID(ctx, sub.ID), sendReaction) + if err != nil { + t.Fatalf("subscriber sendReaction(monoforum): %v", err) + } + sentReactionUpdates, ok := sentReaction.(*tg.Updates) + if !ok || len(sentReactionUpdates.Updates) != 1 { + t.Fatalf("sendReaction = %#v, want one update", sentReaction) + } + sentReactionUpdate, ok := sentReactionUpdates.Updates[0].(*tg.UpdateMessageReactions) + if !ok { + t.Fatalf("sendReaction update = %T, want UpdateMessageReactions", sentReactionUpdates.Updates[0]) + } + if saved, ok := sentReactionUpdate.GetSavedPeerID(); !ok { + t.Fatalf("sendReaction update missing saved_peer_id") + } else if peer, ok := saved.(*tg.PeerUser); !ok || peer.UserID != sub.ID { + t.Fatalf("sendReaction saved_peer_id = %#v, want subscriber %d", saved, sub.ID) + } + if _, err := r.onMessagesSendReaction(WithUserID(ctx, sub.ID), &tg.MessagesSendReactionRequest{ + Peer: monoInput, MsgID: otherMessage.Message.ID, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("cross-saved-peer sendReaction err = %v, want MESSAGE_ID_INVALID", err) + } + + // Add Offer:源消息和目标会话都是 monoforum,自身 topic 由 + // inputReplyToMonoForum(self) 选择;这不是普通 forum reply root。 + addOffer := &tg.MessagesForwardMessagesRequest{ + FromPeer: monoInput, + ID: []int{original.Message.ID}, + RandomID: []int64{8001}, + ToPeer: monoInput, + } + addOffer.SetDropAuthor(true) + addOffer.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerSelf{}}) + offer := tg.SuggestedPost{} + offer.SetPrice(&tg.StarsAmount{Amount: 25}) + offer.SetScheduleDate(2_000_000_000) + addOffer.SetSuggestedPost(offer) + + addOfferResult, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), addOffer) + if err != nil { + t.Fatalf("subscriber Add Offer forwardMessages(monoforum): %v", err) + } + proposal, proposalUpdate := requireMonoforumNewChannelMessage(t, addOfferResult) + if proposal.Message != original.Message.Body { + t.Fatalf("proposal body = %q, want copied source %q", proposal.Message, original.Message.Body) + } + if proposalUpdate.PtsCount != 1 || proposalUpdate.Pts <= original.Message.Pts { + t.Fatalf("proposal pts/count = %d/%d, want one real channel event after %d", proposalUpdate.Pts, proposalUpdate.PtsCount, original.Message.Pts) + } + if _, ok := proposal.GetFwdFrom(); ok { + t.Fatalf("drop_author proposal unexpectedly has fwd_from: %#v", proposal) + } + if saved, ok := proposal.GetSavedPeerID(); !ok { + t.Fatalf("proposal missing saved_peer_id") + } else if peer, ok := saved.(*tg.PeerUser); !ok || peer.UserID != sub.ID { + t.Fatalf("proposal saved_peer_id = %#v, want subscriber %d", saved, sub.ID) + } + proposalSuggested, ok := proposal.GetSuggestedPost() + if !ok { + t.Fatalf("proposal missing suggested_post") + } + if price, ok := proposalSuggested.GetPrice(); !ok { + t.Fatalf("proposal suggested_post missing price") + } else if stars, ok := price.(*tg.StarsAmount); !ok || stars.Amount != 25 { + t.Fatalf("proposal price = %#v, want 25 Stars", price) + } + + // Edit Price/Edit Time:Android 同时带 reply_to、monoforum_peer_id 和冗余 + // top_msg_id;服务端保留真实 reply,接受相等的冗余值但不制造 forum topic root。 + editOffer := &tg.MessagesForwardMessagesRequest{ + FromPeer: monoInput, + ID: []int{proposal.ID}, + RandomID: []int64{8002}, + ToPeer: monoInput, + } + editOffer.SetDropAuthor(true) + editReply := &tg.InputReplyToMessage{ReplyToMsgID: proposal.ID} + editReply.SetMonoforumPeerID(&tg.InputPeerSelf{}) + editOffer.SetReplyTo(editReply) + editOffer.SetTopMsgID(proposal.ID) + edited := tg.SuggestedPost{} + edited.SetPrice(&tg.StarsAmount{Amount: 40}) + edited.SetScheduleDate(2_000_100_000) + editOffer.SetSuggestedPost(edited) + + editResult, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), editOffer) + if err != nil { + t.Fatalf("subscriber Edit Offer forwardMessages(monoforum): %v", err) + } + editedProposal, editedUpdate := requireMonoforumNewChannelMessage(t, editResult) + if editedUpdate.PtsCount != 1 || editedUpdate.Pts != proposalUpdate.Pts+1 { + t.Fatalf("edited proposal pts/count = %d/%d, want %d/1", editedUpdate.Pts, editedUpdate.PtsCount, proposalUpdate.Pts+1) + } + replyHeader, ok := editedProposal.ReplyTo.(*tg.MessageReplyHeader) + if !ok || replyHeader.ReplyToMsgID != proposal.ID { + t.Fatalf("edited proposal reply = %#v, want message %d", editedProposal.ReplyTo, proposal.ID) + } + if topID, ok := replyHeader.GetReplyToTopID(); ok || topID != 0 { + t.Fatalf("edited proposal acquired forum top_msg_id = %d/%v, want absent", topID, ok) + } + editedSuggested, ok := editedProposal.GetSuggestedPost() + if !ok { + t.Fatalf("edited proposal missing suggested_post") + } + if price, ok := editedSuggested.GetPrice(); !ok { + t.Fatalf("edited proposal missing price") + } else if stars, ok := price.(*tg.StarsAmount); !ok || stars.Amount != 40 { + t.Fatalf("edited proposal price = %#v, want 40 Stars", price) + } + if schedule, ok := editedSuggested.GetScheduleDate(); !ok || schedule != 2_000_100_000 { + t.Fatalf("edited proposal schedule = %d/%v, want 2000100000/true", schedule, ok) + } + + beforeReplay, err := channelStore.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{ + MonoforumID: monoID, SavedPeer: subPeer, Limit: 100, + }) + if err != nil { + t.Fatalf("history before replay: %v", err) + } + replayResult, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), editOffer) + if err != nil { + t.Fatalf("exact Edit Offer replay: %v", err) + } + replayedProposal, replayedUpdate := requireMonoforumNewChannelMessage(t, replayResult) + afterReplay, err := channelStore.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{ + MonoforumID: monoID, SavedPeer: subPeer, Limit: 100, + }) + if err != nil { + t.Fatalf("history after replay: %v", err) + } + if replayedProposal.ID != editedProposal.ID || replayedUpdate.Pts != editedUpdate.Pts || afterReplay.Count != beforeReplay.Count { + t.Fatalf("exact replay id/pts/count = %d/%d/%d, want %d/%d/%d", + replayedProposal.ID, replayedUpdate.Pts, afterReplay.Count, + editedProposal.ID, editedUpdate.Pts, beforeReplay.Count) + } + conflict := *editOffer + conflictingSuggested := tg.SuggestedPost{} + conflictingSuggested.SetPrice(&tg.StarsAmount{Amount: 41}) + conflict.SetSuggestedPost(conflictingSuggested) + if _, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), &conflict); err == nil || !strings.Contains(err.Error(), "RANDOM_ID_DUPLICATE") { + t.Fatalf("conflicting Edit Offer replay err = %v, want RANDOM_ID_DUPLICATE", err) + } + + // 精确 id 查询必须服从 saved_peer 过滤,不能把其它订阅者的消息作为转发源。 + crossSource := &tg.MessagesForwardMessagesRequest{ + FromPeer: monoInput, + ID: []int{otherMessage.Message.ID}, + RandomID: []int64{8003}, + ToPeer: monoInput, + } + crossSource.SetDropAuthor(true) + crossSource.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerSelf{}}) + crossSource.SetSuggestedPost(offer) + if _, err := r.onMessagesForwardMessages(WithUserID(ctx, sub.ID), crossSource); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("cross-saved-peer forward source err = %v, want MESSAGE_ID_INVALID", err) + } + + // 管理员可见全部子会话,但目标仍必须显式指定,写入同一 subscriber saved_peer。 + adminOffer := &tg.MessagesForwardMessagesRequest{ + FromPeer: monoInput, + ID: []int{original.Message.ID}, + RandomID: []int64{8004}, + ToPeer: monoInput, + } + adminOffer.SetDropAuthor(true) + adminOffer.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: sub.ID}}) + adminOffer.SetSuggestedPost(offer) + adminResult, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), adminOffer) + if err != nil { + t.Fatalf("admin Add Offer forwardMessages(monoforum): %v", err) + } + adminProposal, _ := requireMonoforumNewChannelMessage(t, adminResult) + if saved, ok := adminProposal.GetSavedPeerID(); !ok { + t.Fatalf("admin proposal missing saved_peer_id") + } else if peer, ok := saved.(*tg.PeerUser); !ok || peer.UserID != sub.ID { + t.Fatalf("admin proposal saved_peer_id = %#v, want subscriber %d", saved, sub.ID) + } +} + +func requireMonoforumNewChannelMessage(t *testing.T, updatesClass tg.UpdatesClass) (*tg.Message, *tg.UpdateNewChannelMessage) { + t.Helper() + updates, ok := updatesClass.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updatesClass) + } + for _, update := range updates.Updates { + newMessage, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + message, ok := newMessage.Message.(*tg.Message) + if !ok { + t.Fatalf("new channel message = %T, want *tg.Message", newMessage.Message) + } + return message, newMessage + } + t.Fatalf("updates missing UpdateNewChannelMessage: %#v", updates.Updates) + return nil, nil +} diff --git a/internal/rpc/messages_reactions_helpers.go b/internal/rpc/messages_reactions_helpers.go index c23c0d69..3359aea7 100644 --- a/internal/rpc/messages_reactions_helpers.go +++ b/internal/rpc/messages_reactions_helpers.go @@ -177,6 +177,8 @@ func messageReactionErr(err error) error { func channelReactionErr(err error) error { switch { + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() case errors.Is(err, domain.ErrReactionInvalid): return reactionInvalidErr() case errors.Is(err, domain.ErrReactionsTooMany): diff --git a/internal/rpc/messages_reactions_send.go b/internal/rpc/messages_reactions_send.go index a1c93c8b..e7c2cf26 100644 --- a/internal/rpc/messages_reactions_send.go +++ b/internal/rpc/messages_reactions_send.go @@ -434,6 +434,9 @@ func (r *Router) channelMessagesReactionsUpdates(ctx context.Context, viewerUser if topID := channelMessageThreadRootID(msg); topID > 0 && topID != id { update.SetTopMsgID(topID) } + if msg.SavedPeer.ID != 0 { + update.SetSavedPeerID(tgPeer(msg.SavedPeer)) + } } updates = append(updates, update) } diff --git a/internal/store/memory/channel_helpers.go b/internal/store/memory/channel_helpers.go index e746b78a..41299c52 100644 --- a/internal/store/memory/channel_helpers.go +++ b/internal/store/memory/channel_helpers.go @@ -458,6 +458,19 @@ func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.C return channel, publicPreviewMember(channel, userID, existing, found), true, nil } +// channelMessageVisibleToViewerLocked applies the message-level half of synthetic monoforum +// access. The channel shell is visible without a channel_members row, but a subscriber may only +// address messages in saved_peer=self; managers may address every subscriber sub-dialog. +func channelMessageVisibleToViewerLocked(channel domain.Channel, member domain.ChannelMember, viewerUserID int64, msg domain.ChannelMessage) bool { + if !channel.Monoforum { + return true + } + if member.CanManageDirectMessages() { + return true + } + return msg.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID}) +} + func (s *ChannelStore) dialogForUserLocked(userID int64, channel domain.Channel) domain.ChannelDialog { return s.dialogForMemberLocked(userID, channel, s.members[channel.ID][userID]) } diff --git a/internal/store/memory/channel_message_history.go b/internal/store/memory/channel_message_history.go index aff44adf..e9f05dc8 100644 --- a/internal/store/memory/channel_message_history.go +++ b/internal/store/memory/channel_message_history.go @@ -368,6 +368,9 @@ func (s *ChannelStore) GetChannelMessages(_ context.Context, viewerUserID, chann if msg.Deleted || msg.ID <= member.AvailableMinID { continue } + if !channelMessageVisibleToViewerLocked(channel, member, viewerUserID, msg) { + continue + } messages = append(messages, cloneChannelMessage(msg)) delete(wanted, msg.ID) } diff --git a/internal/store/memory/channel_monoforum.go b/internal/store/memory/channel_monoforum.go index 1df62b24..5bb55ccb 100644 --- a/internal/store/memory/channel_monoforum.go +++ b/internal/store/memory/channel_monoforum.go @@ -119,6 +119,7 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo Entities: append([]domain.MessageEntity(nil), req.Entities...), Media: req.Media, ReplyTo: req.ReplyTo, + Forward: req.Forward, Pts: pts, } // Store owns the persisted snapshot; callers must not be able to mutate it through diff --git a/internal/store/memory/channel_monoforum_send_test.go b/internal/store/memory/channel_monoforum_send_test.go index 370883f6..8545fa67 100644 --- a/internal/store/memory/channel_monoforum_send_test.go +++ b/internal/store/memory/channel_monoforum_send_test.go @@ -27,8 +27,9 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { } sub := domain.Peer{Type: domain.PeerTypeUser, ID: 42} + forward := &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Date: 1_700_000_999} - m1, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Date: 1_700_001_001}) + m1, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Forward: forward, Date: 1_700_001_001}) if err != nil { t.Fatalf("subscriber send 1: %v", err) } @@ -75,7 +76,7 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { } // 幂等:相同 randomID 返回原消息、不重复。 - dup, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Date: 1_700_001_004}) + dup, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Forward: forward, Date: 1_700_001_004}) if err != nil { t.Fatalf("dup send: %v", err) } @@ -99,6 +100,9 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { if hist.Messages[0].ReplyTo == nil || hist.Messages[0].ReplyTo.MessageID != m1.Message.ID { t.Fatalf("history[0] reply = %+v, want message %d", hist.Messages[0].ReplyTo, m1.Message.ID) } + if oldest := hist.Messages[len(hist.Messages)-1]; oldest.Forward == nil || oldest.Forward.From.ID != 1 || oldest.Forward.Date != 1_700_000_999 { + t.Fatalf("persisted monoforum forward = %+v, want source user 1/date 1700000999", oldest.Forward) + } if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 114, Message: "cross reply", ReplyTo: &domain.MessageReply{MessageID: 999999, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1_700_001_004}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) { t.Fatalf("invalid monoforum reply err = %v, want ErrReplyMessageIDInvalid", err) } @@ -110,7 +114,8 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { // 另一个订阅者的私信不串会话。 other := domain.Peer{Type: domain.PeerTypeUser, ID: 99} - if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 99, SavedPeer: other, RandomID: 201, Message: "other", Date: 1_700_001_005}); err != nil { + otherMessage, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 99, SavedPeer: other, RandomID: 201, Message: "other", Date: 1_700_001_005}) + if err != nil { t.Fatalf("other subscriber send: %v", err) } subHist, _ := store.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: sub, Limit: 10}) @@ -129,6 +134,67 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { t.Fatalf("subscriber channel history leaked message %+v", message) } } + exactMessages, err := store.GetChannelMessages(ctx, 42, monoID, []int{m1.Message.ID, otherMessage.Message.ID}) + if err != nil { + t.Fatalf("subscriber exact monoforum messages: %v", err) + } + if len(exactMessages.Messages) != 1 || exactMessages.Messages[0].ID != m1.Message.ID { + t.Fatalf("subscriber exact monoforum messages = %+v, want only own message %d", exactMessages.Messages, m1.Message.ID) + } + if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ + UserID: 42, ChannelID: monoID, MessageID: m1.Message.ID, + Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"}}, + Date: 1_700_001_006, + }); err != nil { + t.Fatalf("subscriber react to own monoforum message: %v", err) + } + if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ + UserID: 42, ChannelID: monoID, MessageID: otherMessage.Message.ID, + Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f525"}}, + Date: 1_700_001_006, + }); !errors.Is(err, domain.ErrMessageIDInvalid) { + t.Fatalf("subscriber react to another saved_peer err = %v, want ErrMessageIDInvalid", err) + } + subReactions, err := store.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{ + UserID: 42, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID}, + }) + if err != nil { + t.Fatalf("subscriber get monoforum reactions: %v", err) + } + if len(subReactions.Messages) != 1 || subReactions.Messages[0].ID != m1.Message.ID { + t.Fatalf("subscriber monoforum reactions = %+v, want only own message %d", subReactions.Messages, m1.Message.ID) + } + adminReactions, err := store.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{ + UserID: 1, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID}, + }) + if err != nil { + t.Fatalf("admin get monoforum reactions: %v", err) + } + if len(adminReactions.Messages) != 2 { + t.Fatalf("admin monoforum reactions = %+v, want both subscriber messages", adminReactions.Messages) + } + reactionList, err := store.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{ + UserID: 42, ChannelID: monoID, MessageID: m1.Message.ID, Limit: 10, + }) + if err != nil || reactionList.Count != 1 || len(reactionList.Reactions) != 1 { + t.Fatalf("subscriber monoforum reaction list = %+v, %v; want one", reactionList, err) + } + if _, err := store.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{ + UserID: 42, ChannelID: monoID, MessageID: otherMessage.Message.ID, Limit: 10, + }); !errors.Is(err, domain.ErrMessageIDInvalid) { + t.Fatalf("subscriber list another saved_peer reactions err = %v, want ErrMessageIDInvalid", err) + } + reactionLookup, found, err := store.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{ + ViewerUserID: 42, ChannelID: monoID, MessageID: m1.Message.ID, ReactorUserID: 42, + }) + if err != nil || !found || len(reactionLookup.Reactions) != 1 { + t.Fatalf("subscriber monoforum reaction lookup = %+v, %v, %v; want one", reactionLookup, found, err) + } + if _, _, err := store.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{ + ViewerUserID: 42, ChannelID: monoID, MessageID: otherMessage.Message.ID, ReactorUserID: 99, + }); !errors.Is(err, domain.ErrMessageIDInvalid) { + t.Fatalf("subscriber lookup another saved_peer reaction err = %v, want ErrMessageIDInvalid", err) + } diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 42, ChannelID: monoID, Pts: 0, Limit: 100}) if err != nil { t.Fatalf("subscriber channel difference: %v", err) diff --git a/internal/store/memory/channel_reactions.go b/internal/store/memory/channel_reactions.go index 37d76a4e..7d8e2777 100644 --- a/internal/store/memory/channel_reactions.go +++ b/internal/store/memory/channel_reactions.go @@ -46,7 +46,7 @@ func (s *ChannelStore) SetChannelMessageReactions(_ context.Context, req domain. } s.mu.Lock() defer s.mu.Unlock() - channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + channel, member, _, err := s.channelForViewerLocked(req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } @@ -64,7 +64,8 @@ func (s *ChannelStore) SetChannelMessageReactions(_ context.Context, req domain. return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid } msg := s.messages[req.ChannelID][idx] - if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID { + if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID || + !channelMessageVisibleToViewerLocked(channel, member, req.UserID, msg) { return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid } // 仅新增/替换受策略约束;空向量是撤销,策略收紧后也必须允许撤销存量 reaction。 @@ -385,7 +386,7 @@ func (s *ChannelStore) GetChannelMessageReactions(_ context.Context, req domain. } s.mu.RLock() defer s.mu.RUnlock() - channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + channel, member, _, err := s.channelForViewerLocked(req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } @@ -401,7 +402,8 @@ func (s *ChannelStore) GetChannelMessageReactions(_ context.Context, req domain. if _, ok := wanted[msg.ID]; !ok { continue } - if msg.Deleted || msg.ID <= member.AvailableMinID { + if msg.Deleted || msg.ID <= member.AvailableMinID || + !channelMessageVisibleToViewerLocked(channel, member, req.UserID, msg) { continue } item := cloneChannelMessage(msg) @@ -432,7 +434,7 @@ func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain } s.mu.RLock() defer s.mu.RUnlock() - channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + channel, member, _, err := s.channelForViewerLocked(req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsList{}, err } @@ -440,7 +442,8 @@ func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain return domain.ChannelMessageReactionsList{}, domain.ErrChannelRightForbidden } msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID) - if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID || + !channelMessageVisibleToViewerLocked(channel, member, req.UserID, msg) { return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid } rows := s.channelMessageReactionRowsLocked(req.ChannelID, req.MessageID, req.UserID, req.Reaction) @@ -486,12 +489,13 @@ func (s *ChannelStore) FindChannelMessageReaction(_ context.Context, req domain. } s.mu.RLock() defer s.mu.RUnlock() - channel, member, err := s.channelAndMemberLocked(req.ViewerUserID, req.ChannelID) + channel, member, _, err := s.channelForViewerLocked(req.ViewerUserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionLookup{}, false, err } message, ok := s.findMessageLocked(req.ChannelID, req.MessageID) - if !ok || message.Deleted || message.ID <= member.AvailableMinID { + if !ok || message.Deleted || message.ID <= member.AvailableMinID || + !channelMessageVisibleToViewerLocked(channel, member, req.ViewerUserID, message) { return domain.ChannelMessageReactionLookup{}, false, domain.ErrMessageIDInvalid } rows := cloneChannelPeerReactions(s.reactions[req.ChannelID][req.MessageID][req.ReactorUserID]) diff --git a/internal/store/postgres/channel_helpers.go b/internal/store/postgres/channel_helpers.go index 55484d9d..6edb8d60 100644 --- a/internal/store/postgres/channel_helpers.go +++ b/internal/store/postgres/channel_helpers.go @@ -501,6 +501,19 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX, return ch, member, true, nil } +// channelMessageVisibleToViewer applies the message-level half of synthetic monoforum access. +// Subscribers do not have channel_members rows and may only address saved_peer=self; a synthetic +// manager view may address every subscriber sub-dialog. +func channelMessageVisibleToViewer(channel domain.Channel, member domain.ChannelMember, viewerUserID int64, msg domain.ChannelMessage) bool { + if !channel.Monoforum { + return true + } + if member.CanManageDirectMessages() { + return true + } + return msg.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID}) +} + func getChannelByID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (domain.Channel, error) { ch, err := scanChannel(db.QueryRow(ctx, `SELECT `+channelColumns+` FROM channels c WHERE c.id = $1 AND NOT c.deleted`, channelID)) if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/store/postgres/channel_message_history.go b/internal/store/postgres/channel_message_history.go index 62b5e2aa..18917d91 100644 --- a/internal/store/postgres/channel_message_history.go +++ b/internal/store/postgres/channel_message_history.go @@ -516,6 +516,10 @@ func (s *ChannelStore) getChannelMessagesForMember(ctx context.Context, viewerUs AND id = ANY($2::int[]) AND (NOT deleted OR ($4 > 0 AND id = $4)) AND (($3 <= 0 OR id > $3) OR ($4 > 0 AND id = $4))` + if channel.Monoforum && !member.CanManageDirectMessages() { + args = append(args, string(domain.PeerTypeUser), viewerUserID) + where += fmt.Sprintf("\nAND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args)) + } rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages diff --git a/internal/store/postgres/channel_monoforum.go b/internal/store/postgres/channel_monoforum.go index 7baf770c..3d19ab71 100644 --- a/internal/store/postgres/channel_monoforum.go +++ b/internal/store/postgres/channel_monoforum.go @@ -189,6 +189,7 @@ SELECT EXISTS ( Entities: append([]domain.MessageEntity(nil), req.Entities...), Media: req.Media, ReplyTo: req.ReplyTo, + Forward: req.Forward, Pts: pts, } event := domain.ChannelUpdateEvent{ diff --git a/internal/store/postgres/channel_monoforum_send_integration_test.go b/internal/store/postgres/channel_monoforum_send_integration_test.go index a607e699..bfccacd3 100644 --- a/internal/store/postgres/channel_monoforum_send_integration_test.go +++ b/internal/store/postgres/channel_monoforum_send_integration_test.go @@ -79,9 +79,10 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { } suggestedPost := &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}, ScheduleDate: 1700100000} + forward := &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, Date: 1700000999} m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001, - SuggestedPost: suggestedPost, + SuggestedPost: suggestedPost, Forward: forward, }) if err != nil { t.Fatalf("subscriber send 1: %v", err) @@ -128,7 +129,7 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { } // 幂等。 - dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", SuggestedPost: suggestedPost, Date: 1700001004}) + dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", SuggestedPost: suggestedPost, Forward: forward, Date: 1700001004}) if err != nil { t.Fatalf("dup send: %v", err) } @@ -166,6 +167,9 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { if oldest.SuggestedPost == nil || oldest.SuggestedPost.Price == nil || oldest.SuggestedPost.Price.Kind != domain.SuggestedPostPriceStars || oldest.SuggestedPost.Price.Amount != 10 || oldest.SuggestedPost.ScheduleDate != 1700100000 { t.Fatalf("persisted suggested post = %+v, want 10 Stars + schedule", oldest.SuggestedPost) } + if oldest.Forward == nil || oldest.Forward.From.ID != owner.ID || oldest.Forward.Date != 1700000999 { + t.Fatalf("persisted monoforum forward = %+v, want source user %d/date 1700000999", oldest.Forward, owner.ID) + } if newest := hist.Messages[0]; newest.ReplyTo == nil || newest.ReplyTo.MessageID != m1.Message.ID { t.Fatalf("persisted admin reply = %+v, want message %d", newest.ReplyTo, m1.Message.ID) } @@ -175,7 +179,8 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { // 另一个订阅者不串会话。 otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID} - if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 201, Message: "other", Date: 1700001005}); err != nil { + otherMessage, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 201, Message: "other", Date: 1700001005}) + if err != nil { t.Fatalf("other subscriber send: %v", err) } subHist, _ := channels.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: subPeer, Limit: 10}) @@ -191,6 +196,67 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { t.Fatalf("subscriber channel history leaked message %+v", message) } } + exactMessages, err := channels.GetChannelMessages(ctx, sub.ID, monoID, []int{m1.Message.ID, otherMessage.Message.ID}) + if err != nil { + t.Fatalf("subscriber exact monoforum messages: %v", err) + } + if len(exactMessages.Messages) != 1 || exactMessages.Messages[0].ID != m1.Message.ID { + t.Fatalf("subscriber exact monoforum messages = %+v, want only own message %d", exactMessages.Messages, m1.Message.ID) + } + if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ + UserID: sub.ID, ChannelID: monoID, MessageID: m1.Message.ID, + Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"}}, + Date: 1700001006, + }); err != nil { + t.Fatalf("subscriber react to own monoforum message: %v", err) + } + if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ + UserID: sub.ID, ChannelID: monoID, MessageID: otherMessage.Message.ID, + Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f525"}}, + Date: 1700001006, + }); !errors.Is(err, domain.ErrMessageIDInvalid) { + t.Fatalf("subscriber react to another saved_peer err = %v, want ErrMessageIDInvalid", err) + } + subReactions, err := channels.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{ + UserID: sub.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID}, + }) + if err != nil { + t.Fatalf("subscriber get monoforum reactions: %v", err) + } + if len(subReactions.Messages) != 1 || subReactions.Messages[0].ID != m1.Message.ID { + t.Fatalf("subscriber monoforum reactions = %+v, want only own message %d", subReactions.Messages, m1.Message.ID) + } + adminReactions, err := channels.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{ + UserID: owner.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID}, + }) + if err != nil { + t.Fatalf("admin get monoforum reactions: %v", err) + } + if len(adminReactions.Messages) != 2 { + t.Fatalf("admin monoforum reactions = %+v, want both subscriber messages", adminReactions.Messages) + } + reactionList, err := channels.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{ + UserID: sub.ID, ChannelID: monoID, MessageID: m1.Message.ID, Limit: 10, + }) + if err != nil || reactionList.Count != 1 || len(reactionList.Reactions) != 1 { + t.Fatalf("subscriber monoforum reaction list = %+v, %v; want one", reactionList, err) + } + if _, err := channels.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{ + UserID: sub.ID, ChannelID: monoID, MessageID: otherMessage.Message.ID, Limit: 10, + }); !errors.Is(err, domain.ErrMessageIDInvalid) { + t.Fatalf("subscriber list another saved_peer reactions err = %v, want ErrMessageIDInvalid", err) + } + reactionLookup, found, err := channels.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{ + ViewerUserID: sub.ID, ChannelID: monoID, MessageID: m1.Message.ID, ReactorUserID: sub.ID, + }) + if err != nil || !found || len(reactionLookup.Reactions) != 1 { + t.Fatalf("subscriber monoforum reaction lookup = %+v, %v, %v; want one", reactionLookup, found, err) + } + if _, _, err := channels.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{ + ViewerUserID: sub.ID, ChannelID: monoID, MessageID: otherMessage.Message.ID, ReactorUserID: other.ID, + }); !errors.Is(err, domain.ErrMessageIDInvalid) { + t.Fatalf("subscriber lookup another saved_peer reaction err = %v, want ErrMessageIDInvalid", err) + } diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: 0, Limit: 100}) if err != nil { t.Fatalf("subscriber channel difference: %v", err) diff --git a/internal/store/postgres/channel_reaction_messages.go b/internal/store/postgres/channel_reaction_messages.go index 7df5a772..9472e19a 100644 --- a/internal/store/postgres/channel_reaction_messages.go +++ b/internal/store/postgres/channel_reaction_messages.go @@ -37,7 +37,7 @@ func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domai _ = tx.Rollback(ctx) } }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + channel, member, _, err := s.getChannelForViewer(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } @@ -57,7 +57,8 @@ func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domai if err != nil { return domain.ChannelMessageReactionsResult{}, err } - if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID { + if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID || + !channelMessageVisibleToViewer(channel, member, req.UserID, msg) { return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid } // 仅新增/替换受策略约束;空向量是撤销,策略收紧后也必须允许撤销存量 reaction。 @@ -474,7 +475,7 @@ func (s *ChannelStore) GetChannelMessageReactions(ctx context.Context, req domai if len(req.IDs) > domain.MaxGetMessageIDs { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid } - channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } @@ -491,6 +492,10 @@ func (s *ChannelStore) GetChannelMessageReactions(ctx context.Context, req domai args = append(args, member.AvailableMinID) where += fmt.Sprintf(" AND id > $%d", len(args)) } + if channel.Monoforum && !member.CanManageDirectMessages() { + args = append(args, string(domain.PeerTypeUser), req.UserID) + where += fmt.Sprintf(" AND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args)) + } rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages @@ -532,7 +537,7 @@ func (s *ChannelStore) ListChannelMessageReactions(ctx context.Context, req doma if req.Limit <= 0 || req.Limit > domain.MaxChannelMessageReactionListLimit { req.Limit = domain.MaxChannelMessageReactionListLimit } - channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsList{}, err } @@ -543,7 +548,8 @@ func (s *ChannelStore) ListChannelMessageReactions(ctx context.Context, req doma if err != nil { return domain.ChannelMessageReactionsList{}, err } - if msg.Deleted || msg.ID <= member.AvailableMinID { + if msg.Deleted || msg.ID <= member.AvailableMinID || + !channelMessageVisibleToViewer(channel, member, req.UserID, msg) { return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid } baseWhere := []string{"channel_id = $1", "message_id = $2"} @@ -618,7 +624,7 @@ func (s *ChannelStore) FindChannelMessageReaction(ctx context.Context, req domai req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID == 0 { return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid } - channel, member, err := s.getChannelForMember(ctx, s.db, req.ViewerUserID, req.ChannelID) + channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.ViewerUserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionLookup{}, false, err } @@ -626,7 +632,8 @@ func (s *ChannelStore) FindChannelMessageReaction(ctx context.Context, req domai if err != nil { return domain.ChannelMessageReactionLookup{}, false, err } - if message.Deleted || message.ID <= member.AvailableMinID { + if message.Deleted || message.ID <= member.AvailableMinID || + !channelMessageVisibleToViewer(channel, member, req.ViewerUserID, message) { return domain.ChannelMessageReactionLookup{}, false, domain.ErrMessageIDInvalid } rows, err := s.db.Query(ctx, ` diff --git a/internal/store/private_send_idempotency.go b/internal/store/private_send_idempotency.go index 80346527..f1620805 100644 --- a/internal/store/private_send_idempotency.go +++ b/internal/store/private_send_idempotency.go @@ -76,6 +76,7 @@ type monoforumSendFingerprintPayload struct { Entities []domain.MessageEntity `json:"entities"` Media *domain.MessageMedia `json:"media"` ReplyTo *domain.MessageReply `json:"reply_to"` + Forward *domain.MessageForward `json:"forward,omitempty"` Silent bool `json:"silent"` NoForwards bool `json:"noforwards"` SuggestedPost *domain.SuggestedPost `json:"suggested_post,omitempty"` @@ -168,6 +169,7 @@ func MonoforumSendFingerprint(req domain.SendMonoforumMessageRequest) ([]byte, e Entities: req.Entities, Media: req.Media, ReplyTo: req.ReplyTo, + Forward: req.Forward, Silent: req.Silent, NoForwards: req.NoForwards, SuggestedPost: req.SuggestedPost,