fix: sync monoforum suggested post flow

This commit is contained in:
A 2026-07-22 16:08:44 +08:00
parent 511f25efc2
commit 401a8f148e
35 changed files with 2415 additions and 144 deletions

View file

@ -42,7 +42,7 @@ func (s *ChannelStore) ListChannelDialogs(_ context.Context, viewerUserID int64,
continue
}
parentMember, ok := s.members[channel.LinkedMonoforumID][viewerUserID]
if !ok || parentMember.Status != domain.ChannelMemberActive || !isChannelAdmin(parentMember) {
if !ok || !parentMember.CanManageDirectMessages() {
continue
}
channelIDs = append(channelIDs, channelID)
@ -131,7 +131,7 @@ func (s *ChannelStore) GetChannelDialogs(_ context.Context, viewerUserID int64,
continue
}
parentMember, ok := s.members[channel.LinkedMonoforumID][viewerUserID]
if !ok || parentMember.Status != domain.ChannelMemberActive || !isChannelAdmin(parentMember) {
if !ok || !parentMember.CanManageDirectMessages() {
continue
}
member = syntheticMonoforumAdminMember(channel, parentMember)

View file

@ -363,7 +363,7 @@ func (s *ChannelStore) monoforumVisibleToUserLocked(mono domain.Channel, userID
if !ok || parent.Deleted || !parent.BroadcastMessagesAllowed || parent.LinkedMonoforumID != mono.ID {
return false
}
if member, ok := s.members[parent.ID][userID]; ok && member.Status == domain.ChannelMemberActive && isChannelAdmin(member) {
if member, ok := s.members[parent.ID][userID]; ok && member.CanManageDirectMessages() {
return true
}
for _, msg := range s.messages[mono.ID] {
@ -418,7 +418,7 @@ func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.C
}
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
parentMember, ok := s.members[channel.LinkedMonoforumID][userID]
if ok && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) {
if ok && parentMember.CanManageDirectMessages() {
return channel, syntheticMonoforumAdminMember(channel, parentMember), true, nil
}
parent, ok := s.channels[channel.LinkedMonoforumID]

View file

@ -77,6 +77,10 @@ func cloneChannelMessageAction(in *domain.ChannelMessageAction) *domain.ChannelM
}
out.StarGift = &g
}
if in.SuggestedPostPrice != nil {
price := *in.SuggestedPostPrice
out.SuggestedPostPrice = &price
}
out.Wallpaper = domain.CloneWallpaperPtr(in.Wallpaper)
out.Photo = domain.ClonePhotoPtr(in.Photo)
return &out

View file

@ -24,7 +24,7 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
// 静态过滤(不含 offset 锚点的方向条件),结果保持 id 降序。
query := strings.ToLower(strings.TrimSpace(filter.Query))
matched := make([]domain.ChannelMessage, 0, len(items))
monoforumUserView := channel.Monoforum && !isChannelAdmin(member)
monoforumUserView := channel.Monoforum && !member.CanManageDirectMessages()
for _, msg := range items {
if msg.Deleted {
continue

View file

@ -54,7 +54,7 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
return domain.SendChannelMessageResult{}, domain.ErrChannelPrivate
}
parentMember, parentMemberOK := s.members[parent.ID][req.SenderUserID]
isAdmin := parentMemberOK && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember)
isAdmin := parentMemberOK && parentMember.CanManageDirectMessages()
if req.SenderUserID != req.SavedPeer.ID && !isAdmin {
return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired
}
@ -157,7 +157,7 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
s.channels[req.MonoforumID] = channel
recipients := []int64{req.SavedPeer.ID}
for userID, member := range s.members[parent.ID] {
if member.Status == domain.ChannelMemberActive && isChannelAdmin(member) {
if member.CanManageDirectMessages() {
recipients = append(recipients, userID)
}
}
@ -214,7 +214,7 @@ func (s *ChannelStore) ListMonoforumHistory(_ context.Context, filter domain.Mon
}
// ResolveMonoforumSend 按 id 取 monoforum 频道(不要求调用者是 monoforum 成员——订阅者私信频道时
// 并非 monoforum 成员),并返回调用者是否为其母广播频道的创建者/管理员。非 monoforum/不存在 → ErrChannelInvalid。
// 并非 monoforum 成员),并返回调用者是否可管理其母广播频道的 Direct Messages。非 monoforum/不存在 → ErrChannelInvalid。
func (s *ChannelStore) ResolveMonoforumSend(_ context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error) {
if viewerUserID == 0 || monoforumID == 0 {
return domain.Channel{}, false, domain.ErrChannelInvalid
@ -226,8 +226,7 @@ func (s *ChannelStore) ResolveMonoforumSend(_ context.Context, viewerUserID, mon
return domain.Channel{}, false, domain.ErrChannelInvalid
}
member, ok := s.members[mono.LinkedMonoforumID][viewerUserID]
isAdmin := ok && member.Status == domain.ChannelMemberActive &&
(member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin)
isAdmin := ok && member.CanManageDirectMessages()
return cloneChannel(mono), isAdmin, nil
}

View file

@ -73,29 +73,32 @@ type ChannelStore struct {
messages map[int64][]domain.ChannelMessage
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
// paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
top map[int64]map[string]domain.TopMessageReaction
recent map[int64]map[string]domain.RecentMessageReaction
savedTags map[int64]map[string]domain.SavedReactionTag
mentions map[int64]map[int64]map[int]memoryMention
msgViews map[int64]map[int]int
msgViewers map[int64]map[int]map[int64]struct{}
events map[int64][]domain.ChannelUpdateEvent
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
adminLogs map[int64][]domain.ChannelAdminLogEvent
invites map[string]domain.ChannelInvite
importers map[int64]map[int64]domain.ChannelInviteImporter
msgSeq map[int64]int
ptsSeq map[int64]int
logSeq map[int64]int64
randomToID map[channelRandomKey]int
sendSnapshots map[channelMessageReplayKey][]byte
sendFingerprints map[channelMessageReplayKey][]byte
deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent
starsBalances map[int64]int64
channelStarsBalances map[int64]int64
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
readMarks map[int64]channelReadWatermark
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
top map[int64]map[string]domain.TopMessageReaction
recent map[int64]map[string]domain.RecentMessageReaction
savedTags map[int64]map[string]domain.SavedReactionTag
mentions map[int64]map[int64]map[int]memoryMention
msgViews map[int64]map[int]int
msgViewers map[int64]map[int]map[int64]struct{}
events map[int64][]domain.ChannelUpdateEvent
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
adminLogs map[int64][]domain.ChannelAdminLogEvent
invites map[string]domain.ChannelInvite
importers map[int64]map[int64]domain.ChannelInviteImporter
msgSeq map[int64]int
ptsSeq map[int64]int
logSeq map[int64]int64
randomToID map[channelRandomKey]int
sendSnapshots map[channelMessageReplayKey][]byte
sendFingerprints map[channelMessageReplayKey][]byte
deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent
starsBalances map[int64]int64
channelStarsBalances map[int64]int64
tonBalances map[int64]int64
channelTONBalances map[int64]int64
suggestedPostApprovals map[memorySuggestedPostKey]memorySuggestedPostApproval
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
readMarks map[int64]channelReadWatermark
// topicReads 是 per-(channel,user,topic) 已读水位forum 话题独立已读,不碰频道级 member 水位)。
topicReads map[int64]map[int64]map[int]memoryTopicRead
// polls 是共享 poll 权威(与 MessageStore 同一实例nil 时 poll 链路按未接入处理。
@ -110,37 +113,40 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) {
// NewChannelStore creates an in-memory ChannelStore.
func NewChannelStore() *ChannelStore {
return &ChannelStore{
nextID: firstMemoryChannelID,
nextHash: 900000000000,
channels: make(map[int64]domain.Channel),
members: make(map[int64]map[int64]domain.ChannelMember),
dialogs: make(map[int64]map[int64]domain.ChannelDialog),
topics: make(map[int64]map[int]domain.ChannelForumTopic),
messages: make(map[int64][]domain.ChannelMessage),
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
top: make(map[int64]map[string]domain.TopMessageReaction),
recent: make(map[int64]map[string]domain.RecentMessageReaction),
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
mentions: make(map[int64]map[int64]map[int]memoryMention),
msgViews: make(map[int64]map[int]int),
msgViewers: make(map[int64]map[int]map[int64]struct{}),
events: make(map[int64][]domain.ChannelUpdateEvent),
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
invites: make(map[string]domain.ChannelInvite),
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
msgSeq: make(map[int64]int),
ptsSeq: make(map[int64]int),
logSeq: make(map[int64]int64),
randomToID: make(map[channelRandomKey]int),
sendSnapshots: make(map[channelMessageReplayKey][]byte),
sendFingerprints: make(map[channelMessageReplayKey][]byte),
deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent),
starsBalances: make(map[int64]int64),
channelStarsBalances: make(map[int64]int64),
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
readMarks: make(map[int64]channelReadWatermark),
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
nextID: firstMemoryChannelID,
nextHash: 900000000000,
channels: make(map[int64]domain.Channel),
members: make(map[int64]map[int64]domain.ChannelMember),
dialogs: make(map[int64]map[int64]domain.ChannelDialog),
topics: make(map[int64]map[int]domain.ChannelForumTopic),
messages: make(map[int64][]domain.ChannelMessage),
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
top: make(map[int64]map[string]domain.TopMessageReaction),
recent: make(map[int64]map[string]domain.RecentMessageReaction),
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
mentions: make(map[int64]map[int64]map[int]memoryMention),
msgViews: make(map[int64]map[int]int),
msgViewers: make(map[int64]map[int]map[int64]struct{}),
events: make(map[int64][]domain.ChannelUpdateEvent),
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
invites: make(map[string]domain.ChannelInvite),
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
msgSeq: make(map[int64]int),
ptsSeq: make(map[int64]int),
logSeq: make(map[int64]int64),
randomToID: make(map[channelRandomKey]int),
sendSnapshots: make(map[channelMessageReplayKey][]byte),
sendFingerprints: make(map[channelMessageReplayKey][]byte),
deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent),
starsBalances: make(map[int64]int64),
channelStarsBalances: make(map[int64]int64),
tonBalances: make(map[int64]int64),
channelTONBalances: make(map[int64]int64),
suggestedPostApprovals: make(map[memorySuggestedPostKey]memorySuggestedPostApproval),
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
readMarks: make(map[int64]channelReadWatermark),
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
}
}

View file

@ -0,0 +1,421 @@
package memory
import (
"context"
"fmt"
"strings"
"time"
"telesrv/internal/domain"
)
const suggestedPostSettlementAge = 24 * 60 * 60
type memorySuggestedPostKey struct {
monoforumID int64
messageID int
}
type memorySuggestedPostApproval struct {
actorUserID int64
parentID int64
savedPeer domain.Peer
state domain.SuggestedPostLifecycleState
price *domain.SuggestedPostPrice
scheduleDate int
publishedMessageID int
settlementDue int
lastResult domain.ToggleSuggestedPostApprovalResult
}
func (s *ChannelStore) ToggleSuggestedPostApproval(_ context.Context, req domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error) {
if req.UserID == 0 || req.MonoforumID == 0 || req.MessageID <= 0 || (!req.Reject && strings.TrimSpace(req.RejectComment) != "") {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
s.mu.Lock()
defer s.mu.Unlock()
return s.toggleSuggestedPostApprovalLocked(req)
}
func (s *ChannelStore) toggleSuggestedPostApprovalLocked(req domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error) {
mono, ok := s.channels[req.MonoforumID]
if !ok || mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID == 0 {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
parent, ok := s.channels[mono.LinkedMonoforumID]
if !ok || parent.Deleted || !parent.Broadcast || parent.LinkedMonoforumID != mono.ID {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
idx := -1
var original domain.ChannelMessage
for i := range s.messages[mono.ID] {
candidate := s.messages[mono.ID][i]
if candidate.ID == req.MessageID && !candidate.Deleted {
idx, original = i, cloneChannelMessage(candidate)
break
}
}
if idx < 0 || original.SavedPeer.Type != domain.PeerTypeUser || original.SavedPeer.ID == 0 || original.SuggestedPost == nil {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
manager := s.members[parent.ID][req.UserID]
fromSubscriber := original.From.Type == domain.PeerTypeUser
if fromSubscriber {
if !manager.CanManageDirectMessages() || (!req.Reject && !manager.CanPostChannelMessages()) {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
}
} else if req.UserID != original.SavedPeer.ID {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
}
key := memorySuggestedPostKey{monoforumID: mono.ID, messageID: original.ID}
approval, exists := s.suggestedPostApprovals[key]
if exists && approval.state != domain.SuggestedPostStateBalanceLow {
out := cloneSuggestedPostResult(approval.lastResult)
out.Duplicate = true
return out, nil
}
if original.SuggestedPost.Accepted || original.SuggestedPost.Rejected {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostAlreadyHandled
}
price := cloneSuggestedPostPrice(original.SuggestedPost.Price)
scheduleDate := original.SuggestedPost.ScheduleDate
if req.ScheduleDate > 0 {
scheduleDate = req.ScheduleDate
}
if !req.Reject && scheduleDate > 0 && (scheduleDate < req.Date+5*60 || scheduleDate > req.Date+31*24*60*60) {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
recipients := s.monoforumRecipientsLocked(parent.ID, original.SavedPeer.ID)
base := domain.ToggleSuggestedPostApprovalResult{
Monoforum: cloneChannel(mono), Parent: cloneChannel(parent), SavedPeer: original.SavedPeer,
State: domain.SuggestedPostStateBalanceLow, Recipients: recipients,
}
if req.Reject {
original.SuggestedPost.Rejected = true
original.SuggestedPost.Accepted = false
original.Pts = s.nextChannelPtsLocked(mono.ID)
s.messages[mono.ID][idx] = cloneChannelMessage(original)
edit := domain.ChannelUpdateEvent{ChannelID: mono.ID, Type: domain.ChannelUpdateEditMessage, Pts: original.Pts, PtsCount: 1, Date: req.Date, Message: cloneChannelMessage(original), SenderUserID: req.UserID}
s.appendChannelEventLocked(edit)
service, serviceEvent := s.appendSuggestedPostServiceLocked(mono, parent, req.UserID, original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostRejected: true,
SuggestedPostRejectComment: strings.TrimSpace(req.RejectComment), SuggestedPostPrice: price,
})
mono = s.channels[mono.ID]
base.Monoforum, base.State = cloneChannel(mono), domain.SuggestedPostStateRejected
base.OriginalMessage, base.OriginalEvent = cloneChannelMessage(original), cloneChannelEvent(edit)
base.ServiceMessage, base.ServiceEvent = cloneChannelMessage(service), cloneChannelEvent(serviceEvent)
approval = memorySuggestedPostApproval{actorUserID: req.UserID, parentID: parent.ID, savedPeer: original.SavedPeer, state: base.State, price: price, lastResult: cloneSuggestedPostResult(base)}
s.suggestedPostApprovals[key] = approval
return base, nil
}
starsBalance, tonBalance, enough := s.reserveSuggestedPostPaymentLocked(original.SavedPeer.ID, parent.ID, price)
if !enough {
if exists {
out := cloneSuggestedPostResult(approval.lastResult)
out.PayerStarsBalance, out.PayerTONBalance = starsBalance, tonBalance
out.Duplicate = true
return out, nil
}
service, serviceEvent := s.appendSuggestedPostServiceLocked(mono, parent, req.UserID, original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostBalanceTooLow: true,
SuggestedPostScheduleDate: scheduleDate, SuggestedPostPrice: price,
})
base.Monoforum = cloneChannel(s.channels[mono.ID])
base.ServiceMessage, base.ServiceEvent = cloneChannelMessage(service), cloneChannelEvent(serviceEvent)
base.PayerStarsBalance, base.PayerTONBalance = starsBalance, tonBalance
approval = memorySuggestedPostApproval{actorUserID: req.UserID, parentID: parent.ID, savedPeer: original.SavedPeer, state: base.State, price: price, scheduleDate: scheduleDate, lastResult: cloneSuggestedPostResult(base)}
s.suggestedPostApprovals[key] = approval
return base, nil
}
original.SuggestedPost.Accepted = true
original.SuggestedPost.Rejected = false
effectivePublishDate := scheduleDate
if effectivePublishDate == 0 {
// TDesktop deliberately omits schedule_date for "Publish Now", but
// renders the approval service action as an absolute date. Persist one
// effective publication timestamp across the edited suggestion, action
// and approval record instead of leaking an accepted zero date.
effectivePublishDate = req.Date
}
original.SuggestedPost.ScheduleDate = effectivePublishDate
original.Pts = s.nextChannelPtsLocked(mono.ID)
s.messages[mono.ID][idx] = cloneChannelMessage(original)
edit := domain.ChannelUpdateEvent{ChannelID: mono.ID, Type: domain.ChannelUpdateEditMessage, Pts: original.Pts, PtsCount: 1, Date: req.Date, Message: cloneChannelMessage(original), SenderUserID: req.UserID}
s.appendChannelEventLocked(edit)
service, serviceEvent := s.appendSuggestedPostServiceLocked(mono, parent, req.UserID, original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostScheduleDate: effectivePublishDate, SuggestedPostPrice: price,
})
base.Monoforum, base.OriginalMessage, base.OriginalEvent = cloneChannel(s.channels[mono.ID]), cloneChannelMessage(original), cloneChannelEvent(edit)
base.ServiceMessage, base.ServiceEvent = cloneChannelMessage(service), cloneChannelEvent(serviceEvent)
base.PayerStarsBalance, base.PayerTONBalance = starsBalance, tonBalance
base.State = domain.SuggestedPostStateScheduled
approval = memorySuggestedPostApproval{actorUserID: req.UserID, parentID: parent.ID, savedPeer: original.SavedPeer, state: base.State, price: price, scheduleDate: effectivePublishDate}
if effectivePublishDate <= req.Date {
published := s.publishSuggestedPostLocked(parent, original, req.UserID, req.Date)
base.Published = &published
approval.publishedMessageID = published.Message.ID
if price == nil {
base.State = domain.SuggestedPostStateCompleted
} else {
base.State = domain.SuggestedPostStatePublished
approval.settlementDue = req.Date + suggestedPostSettlementAge
}
approval.state = base.State
}
approval.lastResult = cloneSuggestedPostResult(base)
s.suggestedPostApprovals[key] = approval
return base, nil
}
func (s *ChannelStore) ProcessSuggestedPostLifecycle(_ context.Context, req domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error) {
if req.Now == 0 {
req.Now = int(time.Now().Unix())
}
if req.Limit <= 0 || req.Limit > 100 {
req.Limit = 100
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.ToggleSuggestedPostApprovalResult, 0)
for key, approval := range s.suggestedPostApprovals {
if len(out) >= req.Limit {
break
}
if approval.state != domain.SuggestedPostStateScheduled && approval.state != domain.SuggestedPostStatePublished {
continue
}
mono, monoOK := s.channels[key.monoforumID]
parent, parentOK := s.channels[approval.parentID]
if !monoOK || !parentOK {
return out, fmt.Errorf("suggested post lifecycle invariant: missing monoforum %d or parent %d", key.monoforumID, approval.parentID)
}
if mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID != parent.ID || parent.Deleted || !parent.Broadcast || parent.LinkedMonoforumID != mono.ID {
return out, fmt.Errorf("suggested post lifecycle invariant: broken monoforum link %d <-> %d", mono.ID, parent.ID)
}
if approval.scheduleDate <= 0 {
return out, fmt.Errorf("suggested post lifecycle invariant: state %s has zero publish date", approval.state)
}
var original domain.ChannelMessage
originalFound := false
for _, message := range s.messages[mono.ID] {
if message.ID == key.messageID {
original = cloneChannelMessage(message)
originalFound = true
break
}
}
if !originalFound || original.SuggestedPost == nil || !original.SuggestedPost.Accepted || original.SuggestedPost.Rejected {
return out, fmt.Errorf("suggested post lifecycle invariant: missing or invalid accepted suggestion %d/%d", mono.ID, key.messageID)
}
result := domain.ToggleSuggestedPostApprovalResult{Monoforum: cloneChannel(mono), Parent: cloneChannel(parent), SavedPeer: approval.savedPeer, State: approval.state, Recipients: s.monoforumRecipientsLocked(parent.ID, approval.savedPeer.ID)}
changed := false
if approval.state == domain.SuggestedPostStateScheduled && original.Deleted {
if approval.price != nil {
s.refundSuggestedPostPaymentLocked(approval.savedPeer.ID, approval.price)
service, event := s.appendSuggestedPostServiceLocked(mono, parent, approval.actorUserID, approval.savedPeer, key.messageID, req.Now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
result.ServiceMessage, result.ServiceEvent = service, event
}
approval.state, result.State, changed = domain.SuggestedPostStateRefunded, domain.SuggestedPostStateRefunded, true
}
if approval.state == domain.SuggestedPostStateScheduled && approval.scheduleDate <= req.Now {
published := s.publishSuggestedPostLocked(parent, original, approval.actorUserID, req.Now)
result.Published = &published
approval.publishedMessageID = published.Message.ID
if approval.price == nil {
approval.state = domain.SuggestedPostStateCompleted
} else {
approval.state = domain.SuggestedPostStatePublished
approval.settlementDue = req.Now + suggestedPostSettlementAge
}
result.State, changed = approval.state, true
}
if approval.state == domain.SuggestedPostStatePublished {
if approval.price == nil || approval.publishedMessageID <= 0 || approval.settlementDue <= 0 {
return out, fmt.Errorf("suggested post lifecycle invariant: incomplete published state %d/%d", mono.ID, key.messageID)
}
deleted := false
publishedFound := false
for _, message := range s.messages[parent.ID] {
if message.ID == approval.publishedMessageID {
deleted = message.Deleted
publishedFound = true
break
}
}
if !publishedFound {
return out, fmt.Errorf("suggested post lifecycle invariant: missing published message %d/%d", parent.ID, approval.publishedMessageID)
}
deleteDate := s.channelMessageDeleteDateLocked(parent.ID, approval.publishedMessageID)
if deleted && (deleteDate == 0 || deleteDate < approval.settlementDue) {
s.refundSuggestedPostPaymentLocked(approval.savedPeer.ID, approval.price)
service, event := s.appendSuggestedPostServiceLocked(mono, parent, approval.actorUserID, approval.savedPeer, key.messageID, req.Now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
result.ServiceMessage, result.ServiceEvent = service, event
approval.state, result.State, changed = domain.SuggestedPostStateRefunded, domain.SuggestedPostStateRefunded, true
} else if approval.settlementDue <= req.Now {
s.settleSuggestedPostPaymentLocked(parent.ID, approval.price)
service, event := s.appendSuggestedPostServiceLocked(mono, parent, approval.actorUserID, approval.savedPeer, key.messageID, req.Now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostSuccess, SuggestedPostPrice: cloneSuggestedPostPrice(approval.price)})
result.ServiceMessage, result.ServiceEvent = service, event
approval.state, result.State, changed = domain.SuggestedPostStateCompleted, domain.SuggestedPostStateCompleted, true
}
}
if changed {
result.Monoforum, result.Parent = cloneChannel(s.channels[mono.ID]), cloneChannel(s.channels[parent.ID])
approval.lastResult = cloneSuggestedPostResult(result)
s.suggestedPostApprovals[key] = approval
out = append(out, result)
}
}
return out, nil
}
func (s *ChannelStore) channelMessageDeleteDateLocked(channelID int64, messageID int) int {
for i := len(s.events[channelID]) - 1; i >= 0; i-- {
event := s.events[channelID][i]
if event.Type != domain.ChannelUpdateDeleteMessages {
continue
}
for _, id := range event.MessageIDs {
if id == messageID {
return event.Date
}
}
}
return 0
}
func (s *ChannelStore) reserveSuggestedPostPaymentLocked(payerID, parentID int64, price *domain.SuggestedPostPrice) (*domain.StarsBalance, *int64, bool) {
if price == nil {
return nil, nil, true
}
switch price.Kind {
case domain.SuggestedPostPriceStars:
current, ok := s.starsBalances[payerID]
if !ok {
current = domain.DefaultStarsStartingGrant
}
balance := &domain.StarsBalance{UserID: payerID, Balance: current, Granted: true}
if price.Nanos != 0 || current < price.Amount {
return balance, nil, false
}
current -= price.Amount
s.starsBalances[payerID] = current
balance.Balance = current
return balance, nil, true
case domain.SuggestedPostPriceTON:
current := s.tonBalances[payerID]
balance := current
if current < price.Amount {
return nil, &balance, false
}
current -= price.Amount
s.tonBalances[payerID] = current
balance = current
return nil, &balance, true
default:
return nil, nil, false
}
}
func (s *ChannelStore) refundSuggestedPostPaymentLocked(payerID int64, price *domain.SuggestedPostPrice) {
if price == nil {
return
}
if price.Kind == domain.SuggestedPostPriceStars {
s.starsBalances[payerID] += price.Amount
} else if price.Kind == domain.SuggestedPostPriceTON {
s.tonBalances[payerID] += price.Amount
}
}
func (s *ChannelStore) settleSuggestedPostPaymentLocked(parentID int64, price *domain.SuggestedPostPrice) {
if price == nil {
return
}
credit := price.Amount * paidMessageChannelCommissionPermille / 1000
if price.Kind == domain.SuggestedPostPriceStars {
s.channelStarsBalances[parentID] += credit
} else if price.Kind == domain.SuggestedPostPriceTON {
s.channelTONBalances[parentID] += credit
}
}
func (s *ChannelStore) appendSuggestedPostServiceLocked(mono, parent domain.Channel, actor int64, saved domain.Peer, replyID, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent) {
pts := s.nextChannelPtsLocked(mono.ID)
from := domain.Peer{Type: domain.PeerTypeUser, ID: actor}
if member, ok := s.members[parent.ID][actor]; ok && member.CanManageDirectMessages() {
from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}
}
msg := domain.ChannelMessage{ChannelID: mono.ID, ID: s.nextChannelMessageIDLocked(mono.ID), SenderUserID: actor, From: from, SavedPeer: saved, Date: date, Action: cloneChannelMessageAction(&action), ReplyTo: &domain.MessageReply{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: mono.ID}, MessageID: replyID}, Pts: pts}
event := domain.ChannelUpdateEvent{ChannelID: mono.ID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: date, Message: cloneChannelMessage(msg), SenderUserID: actor}
s.messages[mono.ID] = append(s.messages[mono.ID], cloneChannelMessage(msg))
s.appendChannelEventLocked(event)
mono.TopMessageID, mono.Pts = msg.ID, pts
s.channels[mono.ID] = mono
return cloneChannelMessage(msg), cloneChannelEvent(event)
}
func (s *ChannelStore) publishSuggestedPostLocked(parent domain.Channel, original domain.ChannelMessage, actor int64, date int) domain.SendChannelMessageResult {
msg := cloneChannelMessage(original)
msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID = parent.ID, s.nextChannelMessageIDLocked(parent.ID), 0, actor
msg.From, msg.SavedPeer, msg.Date, msg.EditDate, msg.Post = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}, domain.Peer{}, date, 0, true
msg.ReplyTo, msg.PaidMessageStars, msg.Pts, msg.Deleted = nil, 0, s.nextChannelPtsLocked(parent.ID), false
event := domain.ChannelUpdateEvent{ChannelID: parent.ID, Type: domain.ChannelUpdateNewMessage, Pts: msg.Pts, PtsCount: 1, Date: date, Message: cloneChannelMessage(msg), SenderUserID: actor}
s.messages[parent.ID] = append(s.messages[parent.ID], cloneChannelMessage(msg))
s.appendChannelEventLocked(event)
parent.TopMessageID, parent.Pts = msg.ID, msg.Pts
s.channels[parent.ID] = parent
recipients := make([]int64, 0, len(s.members[parent.ID]))
for id, member := range s.members[parent.ID] {
if member.Status == domain.ChannelMemberActive {
recipients = append(recipients, id)
}
}
return domain.SendChannelMessageResult{Channel: cloneChannel(parent), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event), Recipients: uniqueNonZero(recipients, 0)}
}
func (s *ChannelStore) monoforumRecipientsLocked(parentID, subscriberID int64) []int64 {
ids := []int64{subscriberID}
for id, member := range s.members[parentID] {
if member.CanManageDirectMessages() {
ids = append(ids, id)
}
}
return uniqueNonZero(ids, 0)
}
func cloneSuggestedPostPrice(in *domain.SuggestedPostPrice) *domain.SuggestedPostPrice {
if in == nil {
return nil
}
out := *in
return &out
}
func cloneSuggestedPostResult(in domain.ToggleSuggestedPostApprovalResult) domain.ToggleSuggestedPostApprovalResult {
in.Monoforum, in.Parent = cloneChannel(in.Monoforum), cloneChannel(in.Parent)
in.OriginalMessage, in.ServiceMessage = cloneChannelMessage(in.OriginalMessage), cloneChannelMessage(in.ServiceMessage)
in.OriginalEvent, in.ServiceEvent = cloneChannelEvent(in.OriginalEvent), cloneChannelEvent(in.ServiceEvent)
in.Recipients = append([]int64(nil), in.Recipients...)
if in.Published != nil {
p := *in.Published
p.Message = cloneChannelMessage(p.Message)
p.Event = cloneChannelEvent(p.Event)
p.Recipients = append([]int64(nil), p.Recipients...)
in.Published = &p
}
if in.PayerStarsBalance != nil {
b := *in.PayerStarsBalance
in.PayerStarsBalance = &b
}
if in.PayerTONBalance != nil {
b := *in.PayerTONBalance
in.PayerTONBalance = &b
}
return in
}

View file

@ -0,0 +1,276 @@
package memory
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func newSuggestedPostMemoryFixture(t *testing.T) (*ChannelStore, domain.Channel, domain.Channel, domain.Peer) {
t.Helper()
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: 1, Title: "Suggestions", Broadcast: true, Date: 1_700_000_000})
if err != nil {
t.Fatal(err)
}
enabled, err := store.SetPaidMessagesPrice(ctx, 1, created.Channel.ID, 0, true)
if err != nil {
t.Fatal(err)
}
mono := store.channels[enabled.Channel.LinkedMonoforumID]
return store, store.channels[created.Channel.ID], mono, domain.Peer{Type: domain.PeerTypeUser, ID: 42}
}
func TestMonoforumManagerRequiresManageDirectMessages(t *testing.T) {
ctx := context.Background()
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
store.mu.Lock()
store.members[parent.ID][2] = domain.ChannelMember{ChannelID: parent.ID, UserID: 2, Role: domain.ChannelRoleAdmin, Status: domain.ChannelMemberActive, AdminRights: domain.ChannelAdminRights{PostMessages: true}}
store.mu.Unlock()
if _, manager, err := store.ResolveMonoforumSend(ctx, 2, mono.ID); err != nil || manager {
t.Fatalf("ordinary admin resolved as manager: manager=%v err=%v", manager, err)
}
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: 2, SavedPeer: subscriber, RandomID: 1, Message: "must not send", Date: 1_700_000_010}); !errors.Is(err, domain.ErrChannelAdminRequired) {
t.Fatalf("ordinary admin send err=%v, want admin required", err)
}
fromSubscriber, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 10, Message: "private", Date: 1_700_000_010})
if err != nil {
t.Fatal(err)
}
if containsInt64(fromSubscriber.Recipients, 2) {
t.Fatalf("ordinary admin leaked into recipients: %v", fromSubscriber.Recipients)
}
dialogs, err := store.ListChannelDialogs(ctx, 2, domain.DialogFilter{Limit: 20})
if err != nil {
t.Fatal(err)
}
for _, dialog := range dialogs.Dialogs {
if dialog.Peer.ID == mono.ID {
t.Fatalf("ordinary admin received monoforum dialog")
}
}
store.mu.Lock()
member := store.members[parent.ID][2]
member.AdminRights.ManageDirectMessages = true
store.members[parent.ID][2] = member
store.mu.Unlock()
if _, manager, err := store.ResolveMonoforumSend(ctx, 2, mono.ID); err != nil || !manager {
t.Fatalf("DM manager not resolved: manager=%v err=%v", manager, err)
}
dialogs, err = store.ListChannelDialogs(ctx, 2, domain.DialogFilter{Limit: 20})
if err != nil {
t.Fatal(err)
}
foundMono := false
for _, dialog := range dialogs.Dialogs {
foundMono = foundMono || dialog.Peer.ID == mono.ID
}
if !foundMono {
t.Fatalf("DM manager missing monoforum dialog")
}
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: 2, SavedPeer: subscriber, RandomID: 2, Message: "allowed", Date: 1_700_000_011}); err != nil {
t.Fatalf("DM manager send: %v", err)
}
}
func TestSuggestedPostStarsApprovalRefundAndSettlement(t *testing.T) {
ctx := context.Background()
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
store.starsBalances[subscriber.ID] = 100
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 11, Message: "publish me", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_000_100})
if err != nil {
t.Fatal(err)
}
approved, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, Date: 1_700_000_200})
if err != nil {
t.Fatal(err)
}
if approved.State != domain.SuggestedPostStatePublished || approved.OriginalEvent.Type != domain.ChannelUpdateEditMessage || approved.ServiceMessage.Action == nil || approved.ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostApproval || approved.Published == nil {
t.Fatalf("approval result=%+v", approved)
}
if approved.OriginalMessage.SuggestedPost.ScheduleDate != 1_700_000_200 || approved.ServiceMessage.Action.SuggestedPostScheduleDate != 1_700_000_200 {
t.Fatalf("immediate approval dates original/action=%d/%d, want commit date", approved.OriginalMessage.SuggestedPost.ScheduleDate, approved.ServiceMessage.Action.SuggestedPostScheduleDate)
}
if store.starsBalances[subscriber.ID] != 90 || store.channelStarsBalances[parent.ID] != 0 {
t.Fatalf("escrow/channel balances=%d/%d, want 90/0", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
}
duplicate, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, Date: 1_700_000_201})
if err != nil || !duplicate.Duplicate || store.starsBalances[subscriber.ID] != 90 {
t.Fatalf("duplicate=%+v err=%v balance=%d", duplicate, err, store.starsBalances[subscriber.ID])
}
if duplicate.OriginalMessage.SuggestedPost.ScheduleDate != 1_700_000_200 || duplicate.ServiceMessage.Action.SuggestedPostScheduleDate != 1_700_000_200 {
t.Fatalf("duplicate changed immediate approval date: %+v", duplicate)
}
store.mu.Lock()
for i := range store.messages[parent.ID] {
if store.messages[parent.ID][i].ID == approved.Published.Message.ID {
store.messages[parent.ID][i].Deleted = true
}
}
store.mu.Unlock()
lifecycle, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_000_300, Limit: 10})
if err != nil || len(lifecycle) != 1 || lifecycle[0].State != domain.SuggestedPostStateRefunded || lifecycle[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostRefund {
t.Fatalf("refund lifecycle=%+v err=%v", lifecycle, err)
}
if store.starsBalances[subscriber.ID] != 100 || store.channelStarsBalances[parent.ID] != 0 {
t.Fatalf("refund balances=%d/%d, want 100/0", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
}
second, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 12, Message: "settle me", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 20}}, Date: 1_700_000_400})
if err != nil {
t.Fatal(err)
}
settling, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: second.Message.ID, Date: 1_700_000_500})
if err != nil || settling.State != domain.SuggestedPostStatePublished {
t.Fatalf("second approval=%+v err=%v", settling, err)
}
lifecycle, err = store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_000_500 + suggestedPostSettlementAge, Limit: 10})
if err != nil || len(lifecycle) != 1 || lifecycle[0].State != domain.SuggestedPostStateCompleted || lifecycle[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostSuccess {
t.Fatalf("success lifecycle=%+v err=%v", lifecycle, err)
}
if store.starsBalances[subscriber.ID] != 80 || store.channelStarsBalances[parent.ID] != 17 {
t.Fatalf("settled balances=%d/%d, want 80/17", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
}
}
func TestSuggestedPostLowBalanceRetryScheduleAndRoleMatrix(t *testing.T) {
ctx := context.Background()
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
store.starsBalances[subscriber.ID] = 5
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 21, Message: "later", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_001_000})
if err != nil {
t.Fatal(err)
}
low, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, ScheduleDate: 1_700_001_400, Date: 1_700_001_000})
if err != nil || low.State != domain.SuggestedPostStateBalanceLow || low.ServiceMessage.Action == nil || !low.ServiceMessage.Action.SuggestedPostBalanceTooLow {
t.Fatalf("low=%+v err=%v", low, err)
}
again, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, ScheduleDate: 1_700_001_400, Date: 1_700_001_001})
if err != nil || !again.Duplicate {
t.Fatalf("low retry=%+v err=%v", again, err)
}
store.starsBalances[subscriber.ID] = 20
accepted, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, ScheduleDate: 1_700_001_400, Date: 1_700_001_050})
if err != nil || accepted.State != domain.SuggestedPostStateScheduled || accepted.Published != nil {
t.Fatalf("scheduled=%+v err=%v", accepted, err)
}
due, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_001_400, Limit: 10})
if err != nil || len(due) != 1 || due[0].Published == nil || due[0].State != domain.SuggestedPostStatePublished {
t.Fatalf("due=%+v err=%v", due, err)
}
store.mu.Lock()
store.members[parent.ID][2] = domain.ChannelMember{ChannelID: parent.ID, UserID: 2, Role: domain.ChannelRoleAdmin, Status: domain.ChannelMemberActive, AdminRights: domain.ChannelAdminRights{ManageDirectMessages: true}}
store.mu.Unlock()
third, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 22, Message: "decline only", SuggestedPost: &domain.SuggestedPost{}, Date: 1_700_002_000})
if err != nil {
t.Fatal(err)
}
if _, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 2, MonoforumID: mono.ID, MessageID: third.Message.ID, Date: 1_700_002_100}); !errors.Is(err, domain.ErrSuggestedPostApprovalForbidden) {
t.Fatalf("manager without post right approve err=%v", err)
}
rejected, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 2, MonoforumID: mono.ID, MessageID: third.Message.ID, Reject: true, RejectComment: "no", Date: 1_700_002_100})
if err != nil || rejected.State != domain.SuggestedPostStateRejected {
t.Fatalf("decline=%+v err=%v", rejected, err)
}
}
func TestChannelAuthoredSuggestedPostAcceptedBySubscriber(t *testing.T) {
ctx := context.Background()
store, _, mono, subscriber := newSuggestedPostMemoryFixture(t)
fromChannel, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: 1, SavedPeer: subscriber, RandomID: 31, Message: "channel proposal", SuggestedPost: &domain.SuggestedPost{}, Date: 1_700_003_000})
if err != nil {
t.Fatal(err)
}
result, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: subscriber.ID, MonoforumID: mono.ID, MessageID: fromChannel.Message.ID, Date: 1_700_003_100})
if err != nil || result.State != domain.SuggestedPostStateCompleted || result.Published == nil {
t.Fatalf("subscriber approval=%+v err=%v", result, err)
}
}
func TestScheduledSuggestedPostDeletionRefundsBeforePublication(t *testing.T) {
ctx := context.Background()
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
store.starsBalances[subscriber.ID] = 30
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 41, Message: "cancel scheduled", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_004_000})
if err != nil {
t.Fatal(err)
}
accepted, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, ScheduleDate: 1_700_004_600, Date: 1_700_004_000})
if err != nil || accepted.State != domain.SuggestedPostStateScheduled {
t.Fatalf("accepted=%+v err=%v", accepted, err)
}
store.mu.Lock()
for i := range store.messages[mono.ID] {
if store.messages[mono.ID][i].ID == suggestion.Message.ID {
store.messages[mono.ID][i].Deleted = true
}
}
store.mu.Unlock()
resolved, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_004_100, Limit: 10})
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateRefunded || resolved[0].Published != nil {
t.Fatalf("resolved=%+v err=%v", resolved, err)
}
if store.starsBalances[subscriber.ID] != 30 || store.channelStarsBalances[parent.ID] != 0 {
t.Fatalf("balances=%d/%d", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
}
}
func TestSuggestedPostLifecycleFailsFastOnCorruptAcceptedState(t *testing.T) {
ctx := context.Background()
store, _, mono, subscriber := newSuggestedPostMemoryFixture(t)
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber,
RandomID: 61, Message: "must fail fast", SuggestedPost: &domain.SuggestedPost{}, Date: 1_700_006_000,
})
if err != nil {
t.Fatal(err)
}
if _, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID,
ScheduleDate: 1_700_006_600, Date: 1_700_006_000,
}); err != nil {
t.Fatal(err)
}
store.mu.Lock()
for i, message := range store.messages[mono.ID] {
if message.ID == suggestion.Message.ID {
store.messages[mono.ID] = append(store.messages[mono.ID][:i], store.messages[mono.ID][i+1:]...)
break
}
}
store.mu.Unlock()
if _, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_006_100, Limit: 10}); err == nil {
t.Fatal("corrupt accepted suggestion was silently skipped")
}
}
func TestSuggestedPostDeletedAfterMinimumAgeStillSettles(t *testing.T) {
ctx := context.Background()
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
store.starsBalances[subscriber.ID] = 30
suggestion, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber, RandomID: 51, Message: "late delete", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_005_000})
if err != nil {
t.Fatal(err)
}
approvedAt := 1_700_005_100
approved, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: 1, MonoforumID: mono.ID, MessageID: suggestion.Message.ID, Date: approvedAt})
if err != nil || approved.Published == nil {
t.Fatalf("approved=%+v err=%v", approved, err)
}
due := approvedAt + suggestedPostSettlementAge
if _, err := store.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{UserID: 1, ChannelID: parent.ID, IDs: []int{approved.Published.Message.ID}, Date: due + 1}); err != nil {
t.Fatal(err)
}
resolved, err := store.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: due + 2, Limit: 10})
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateCompleted || resolved[0].ServiceMessage.Action == nil || resolved[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostSuccess {
t.Fatalf("resolved=%+v err=%v", resolved, err)
}
if store.starsBalances[subscriber.ID] != 20 || store.channelStarsBalances[parent.ID] != 8 {
t.Fatalf("balances=%d/%d, want 20/8", store.starsBalances[subscriber.ID], store.channelStarsBalances[parent.ID])
}
}

View file

@ -49,7 +49,7 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
if msg.Deleted {
continue
}
if channel.Monoforum && !isChannelAdmin(member) && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
if channel.Monoforum && !member.CanManageDirectMessages() && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
continue
}
if msg.ID <= member.AvailableMinID {
@ -72,7 +72,7 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
events := make([]domain.ChannelUpdateEvent, 0, limit)
lastPts := req.Pts
var visibleMonoforumMessageIDs map[int]struct{}
if channel.Monoforum && !isChannelAdmin(member) {
if channel.Monoforum && !member.CanManageDirectMessages() {
visibleMonoforumMessageIDs = make(map[int]struct{})
savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}
for _, message := range s.messages[req.ChannelID] {
@ -90,7 +90,7 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
if !ok {
continue
}
if channel.Monoforum && !isChannelAdmin(member) {
if channel.Monoforum && !member.CanManageDirectMessages() {
visible, ok = filterMonoforumEventForUser(visible, req.UserID, visibleMonoforumMessageIDs)
if !ok {
continue