fix: sync monoforum suggested post flow
This commit is contained in:
parent
511f25efc2
commit
401a8f148e
35 changed files with 2415 additions and 144 deletions
34
internal/app/channels/service_suggested_post.go
Normal file
34
internal/app/channels/service_suggested_post.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type suggestedPostStore interface {
|
||||
ToggleSuggestedPostApproval(context.Context, domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error)
|
||||
ProcessSuggestedPostLifecycle(context.Context, domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error)
|
||||
}
|
||||
|
||||
func (s *Service) ToggleSuggestedPostApproval(ctx context.Context, req domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
if s == nil || s.channels == nil || req.UserID == 0 || req.MonoforumID == 0 || req.MessageID <= 0 {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
store, ok := s.channels.(suggestedPostStore)
|
||||
if !ok {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return store.ToggleSuggestedPostApproval(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) ProcessSuggestedPostLifecycle(ctx context.Context, req domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
if s == nil || s.channels == nil {
|
||||
return nil, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
store, ok := s.channels.(suggestedPostStore)
|
||||
if !ok {
|
||||
return nil, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return store.ProcessSuggestedPostLifecycle(ctx, req)
|
||||
}
|
||||
|
|
@ -505,6 +505,26 @@ type ChannelMember struct {
|
|||
Guest bool
|
||||
}
|
||||
|
||||
// CanManageDirectMessages reports whether this active parent-channel member may
|
||||
// see and address every subscriber topic in the linked direct-messages
|
||||
// monoforum. Telegram deliberately does not grant this capability to an
|
||||
// ordinary channel administrator: the explicit manage_direct_messages right is
|
||||
// required (creators have the capability implicitly).
|
||||
func (m ChannelMember) CanManageDirectMessages() bool {
|
||||
return m.Status == ChannelMemberActive &&
|
||||
(m.Role == ChannelRoleCreator ||
|
||||
(m.Role == ChannelRoleAdmin && m.AdminRights.ManageDirectMessages))
|
||||
}
|
||||
|
||||
// CanPostChannelMessages reports whether this active member may publish a post
|
||||
// to a broadcast channel. Suggested-post managers need this in addition to
|
||||
// CanManageDirectMessages when approving a subscriber-authored suggestion.
|
||||
func (m ChannelMember) CanPostChannelMessages() bool {
|
||||
return m.Status == ChannelMemberActive &&
|
||||
(m.Role == ChannelRoleCreator ||
|
||||
(m.Role == ChannelRoleAdmin && m.AdminRights.PostMessages))
|
||||
}
|
||||
|
||||
// ChannelDialog is the current user's owner-view dialog state for a channel.
|
||||
type ChannelDialog struct {
|
||||
UserID int64
|
||||
|
|
@ -570,7 +590,10 @@ const (
|
|||
ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper"
|
||||
// ChannelActionChangeCommunity maps messageActionChangeCommunity. A non-zero
|
||||
// CommunityID means linked; zero means unlinked.
|
||||
ChannelActionChangeCommunity ChannelMessageActionType = "change_community"
|
||||
ChannelActionChangeCommunity ChannelMessageActionType = "change_community"
|
||||
ChannelActionSuggestedPostApproval ChannelMessageActionType = "suggested_post_approval"
|
||||
ChannelActionSuggestedPostSuccess ChannelMessageActionType = "suggested_post_success"
|
||||
ChannelActionSuggestedPostRefund ChannelMessageActionType = "suggested_post_refund"
|
||||
)
|
||||
|
||||
// ChannelMessageAction describes a service action without depending on tg.*.
|
||||
|
|
@ -609,6 +632,15 @@ type ChannelMessageAction struct {
|
|||
Wallpaper *Wallpaper
|
||||
// Photo 仅 chat_edit_photo 服务消息使用。
|
||||
Photo *Photo
|
||||
// Suggested-post lifecycle actions share the immutable price snapshot. The
|
||||
// approval action additionally uses the reject/balance/schedule fields;
|
||||
// refund uses PayerInitiated.
|
||||
SuggestedPostRejected bool
|
||||
SuggestedPostBalanceTooLow bool
|
||||
SuggestedPostRejectComment string
|
||||
SuggestedPostScheduleDate int
|
||||
SuggestedPostPrice *SuggestedPostPrice
|
||||
SuggestedPostPayerInitiated bool
|
||||
}
|
||||
|
||||
// ChannelMessage is a single stored message in a channel/supergroup.
|
||||
|
|
@ -1491,6 +1523,59 @@ type SendMonoforumMessageRequest struct {
|
|||
Date int
|
||||
}
|
||||
|
||||
// ToggleSuggestedPostApprovalRequest is the domain command behind
|
||||
// messages.toggleSuggestedPostApproval. MessageID addresses the immutable
|
||||
// suggestion in one monoforum subscriber sub-dialog.
|
||||
type ToggleSuggestedPostApprovalRequest struct {
|
||||
UserID int64
|
||||
MonoforumID int64
|
||||
MessageID int
|
||||
Reject bool
|
||||
RejectComment string
|
||||
ScheduleDate int
|
||||
Date int
|
||||
}
|
||||
|
||||
// SuggestedPostLifecycleState is persisted so approval, scheduled publication,
|
||||
// settlement and refund remain idempotent across restarts.
|
||||
type SuggestedPostLifecycleState string
|
||||
|
||||
const (
|
||||
SuggestedPostStateBalanceLow SuggestedPostLifecycleState = "balance_low"
|
||||
SuggestedPostStateRejected SuggestedPostLifecycleState = "rejected"
|
||||
SuggestedPostStateScheduled SuggestedPostLifecycleState = "scheduled"
|
||||
SuggestedPostStatePublished SuggestedPostLifecycleState = "published"
|
||||
SuggestedPostStateCompleted SuggestedPostLifecycleState = "completed"
|
||||
SuggestedPostStateRefunded SuggestedPostLifecycleState = "refunded"
|
||||
)
|
||||
|
||||
// ToggleSuggestedPostApprovalResult contains every durable update produced by
|
||||
// one command or lifecycle transition. OriginalEvent is an edit in the
|
||||
// monoforum; ServiceEvent is the approval/success/refund service message; an
|
||||
// optional Published result is the broadcast post.
|
||||
type ToggleSuggestedPostApprovalResult struct {
|
||||
Monoforum Channel
|
||||
Parent Channel
|
||||
SavedPeer Peer
|
||||
State SuggestedPostLifecycleState
|
||||
OriginalMessage ChannelMessage
|
||||
OriginalEvent ChannelUpdateEvent
|
||||
ServiceMessage ChannelMessage
|
||||
ServiceEvent ChannelUpdateEvent
|
||||
Published *SendChannelMessageResult
|
||||
Recipients []int64
|
||||
PayerStarsBalance *StarsBalance
|
||||
PayerTONBalance *int64
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// SuggestedPostLifecycleRequest bounds one worker pass; stores must use an
|
||||
// indexed seek and row locks rather than scanning every approval.
|
||||
type SuggestedPostLifecycleRequest struct {
|
||||
Now int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ChannelSendReplayRequest addresses either a regular channel send (SavedPeer is zero) or one
|
||||
// monoforum sub-dialog send (SavedPeer is the subscriber scope). Lookup is read-only and must
|
||||
// never re-run membership/permission checks or allocate pts/message ids.
|
||||
|
|
|
|||
|
|
@ -6,38 +6,41 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
ErrChannelInvalid = errors.New("channel invalid")
|
||||
ErrChannelPrivate = errors.New("channel private")
|
||||
ErrChannelTitleInvalid = errors.New("channel title invalid")
|
||||
ErrChannelUserBanned = errors.New("user banned in channel")
|
||||
ErrChannelWriteForbidden = errors.New("chat write forbidden")
|
||||
ErrChannelAdminRequired = errors.New("chat admin required")
|
||||
ErrChannelNotModified = errors.New("chat not modified")
|
||||
ErrChannelForumMissing = errors.New("channel forum missing")
|
||||
ErrChannelMonoforumUnsupported = errors.New("channel monoforum unsupported")
|
||||
ErrLinkNotModified = errors.New("discussion link not modified")
|
||||
ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed")
|
||||
ErrBroadcastIDInvalid = errors.New("broadcast id invalid")
|
||||
ErrMegagroupIDInvalid = errors.New("megagroup id invalid")
|
||||
ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden")
|
||||
ErrChatPublicRequired = errors.New("chat public required")
|
||||
ErrChannelUserCreator = errors.New("channel user creator")
|
||||
ErrChannelRightForbidden = errors.New("channel right forbidden")
|
||||
ErrPersistentTimestamp = errors.New("persistent timestamp invalid")
|
||||
ErrInviteHashEmpty = errors.New("invite hash empty")
|
||||
ErrInviteHashInvalid = errors.New("invite hash invalid")
|
||||
ErrInviteHashExpired = errors.New("invite hash expired")
|
||||
ErrInvitePermanent = errors.New("chat invite permanent")
|
||||
ErrInviteRevokedMissing = errors.New("invite revoked missing")
|
||||
ErrInviteRequestSent = errors.New("invite request sent")
|
||||
ErrHideRequesterMissing = errors.New("hide requester missing")
|
||||
ErrUsersTooMuch = errors.New("users too much")
|
||||
ErrUserAlreadyParticipant = errors.New("user already participant")
|
||||
ErrUserKicked = errors.New("user kicked")
|
||||
ErrUserNotParticipant = errors.New("user not participant")
|
||||
ErrBotGroupsBlocked = errors.New("bot groups blocked")
|
||||
ErrReactionInvalid = errors.New("reaction invalid")
|
||||
ErrReactionsTooMany = errors.New("reactions too many")
|
||||
ErrChannelInvalid = errors.New("channel invalid")
|
||||
ErrChannelPrivate = errors.New("channel private")
|
||||
ErrChannelTitleInvalid = errors.New("channel title invalid")
|
||||
ErrChannelUserBanned = errors.New("user banned in channel")
|
||||
ErrChannelWriteForbidden = errors.New("chat write forbidden")
|
||||
ErrChannelAdminRequired = errors.New("chat admin required")
|
||||
ErrChannelNotModified = errors.New("chat not modified")
|
||||
ErrChannelForumMissing = errors.New("channel forum missing")
|
||||
ErrChannelMonoforumUnsupported = errors.New("channel monoforum unsupported")
|
||||
ErrLinkNotModified = errors.New("discussion link not modified")
|
||||
ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed")
|
||||
ErrBroadcastIDInvalid = errors.New("broadcast id invalid")
|
||||
ErrMegagroupIDInvalid = errors.New("megagroup id invalid")
|
||||
ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden")
|
||||
ErrChatPublicRequired = errors.New("chat public required")
|
||||
ErrChannelUserCreator = errors.New("channel user creator")
|
||||
ErrChannelRightForbidden = errors.New("channel right forbidden")
|
||||
ErrPersistentTimestamp = errors.New("persistent timestamp invalid")
|
||||
ErrInviteHashEmpty = errors.New("invite hash empty")
|
||||
ErrInviteHashInvalid = errors.New("invite hash invalid")
|
||||
ErrInviteHashExpired = errors.New("invite hash expired")
|
||||
ErrInvitePermanent = errors.New("chat invite permanent")
|
||||
ErrInviteRevokedMissing = errors.New("invite revoked missing")
|
||||
ErrInviteRequestSent = errors.New("invite request sent")
|
||||
ErrHideRequesterMissing = errors.New("hide requester missing")
|
||||
ErrUsersTooMuch = errors.New("users too much")
|
||||
ErrUserAlreadyParticipant = errors.New("user already participant")
|
||||
ErrUserKicked = errors.New("user kicked")
|
||||
ErrUserNotParticipant = errors.New("user not participant")
|
||||
ErrBotGroupsBlocked = errors.New("bot groups blocked")
|
||||
ErrReactionInvalid = errors.New("reaction invalid")
|
||||
ErrReactionsTooMany = errors.New("reactions too many")
|
||||
ErrSuggestedPostInvalid = errors.New("suggested post invalid")
|
||||
ErrSuggestedPostAlreadyHandled = errors.New("suggested post already handled")
|
||||
ErrSuggestedPostApprovalForbidden = errors.New("suggested post approval forbidden")
|
||||
)
|
||||
|
||||
// SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation.
|
||||
|
|
|
|||
|
|
@ -21,20 +21,21 @@ type StarsBalance struct {
|
|||
type StarsTransactionReason string
|
||||
|
||||
const (
|
||||
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
|
||||
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
|
||||
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
|
||||
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
|
||||
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
|
||||
StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer"
|
||||
StarsReasonGiftResale StarsTransactionReason = "gift_resale"
|
||||
StarsReasonGiftOffer StarsTransactionReason = "gift_offer"
|
||||
StarsReasonGiftAuction StarsTransactionReason = "gift_auction"
|
||||
StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade"
|
||||
StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details"
|
||||
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
|
||||
StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费
|
||||
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
|
||||
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
|
||||
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
|
||||
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
|
||||
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
|
||||
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
|
||||
StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer"
|
||||
StarsReasonGiftResale StarsTransactionReason = "gift_resale"
|
||||
StarsReasonGiftOffer StarsTransactionReason = "gift_offer"
|
||||
StarsReasonGiftAuction StarsTransactionReason = "gift_auction"
|
||||
StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade"
|
||||
StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details"
|
||||
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
|
||||
StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费
|
||||
StarsReasonSuggestedPost StarsTransactionReason = "suggested_post"
|
||||
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
|
||||
)
|
||||
|
||||
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。
|
||||
|
|
|
|||
|
|
@ -113,6 +113,9 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
|
|||
if msg.Action == nil {
|
||||
msg.Action = &tg.MessageActionEmpty{}
|
||||
}
|
||||
if m.SavedPeer.ID != 0 {
|
||||
msg.SetSavedPeerID(tgPeer(m.SavedPeer))
|
||||
}
|
||||
if reply := tgMessageReplyHeader(domain.Message{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: m.ChannelID},
|
||||
ReplyTo: m.ReplyTo,
|
||||
|
|
@ -140,7 +143,18 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
|
|||
msg.SetSavedPeerID(tgPeer(m.SavedPeer))
|
||||
}
|
||||
if suggested, ok := tgSuggestedPost(m.SuggestedPost); ok {
|
||||
msg.SetSuggestedPost(suggested)
|
||||
if m.Post {
|
||||
if m.SuggestedPost != nil && m.SuggestedPost.Accepted && m.SuggestedPost.Price != nil {
|
||||
switch m.SuggestedPost.Price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
msg.SetPaidSuggestedPostStars(true)
|
||||
case domain.SuggestedPostPriceTON:
|
||||
msg.SetPaidSuggestedPostTon(true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
msg.SetSuggestedPost(suggested)
|
||||
}
|
||||
}
|
||||
if m.PaidMessageStars > 0 {
|
||||
msg.SetPaidMessageStars(m.PaidMessageStars)
|
||||
|
|
@ -303,6 +317,42 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
|
|||
out.SetCommunityID(action.CommunityID)
|
||||
}
|
||||
return out
|
||||
case domain.ChannelActionSuggestedPostApproval:
|
||||
out := &tg.MessageActionSuggestedPostApproval{
|
||||
Rejected: action.SuggestedPostRejected,
|
||||
BalanceTooLow: action.SuggestedPostBalanceTooLow,
|
||||
}
|
||||
if action.SuggestedPostRejectComment != "" {
|
||||
out.SetRejectComment(action.SuggestedPostRejectComment)
|
||||
}
|
||||
if action.SuggestedPostScheduleDate > 0 {
|
||||
out.SetScheduleDate(action.SuggestedPostScheduleDate)
|
||||
}
|
||||
if price := tgSuggestedPostPrice(action.SuggestedPostPrice); price != nil {
|
||||
out.SetPrice(price)
|
||||
}
|
||||
return out
|
||||
case domain.ChannelActionSuggestedPostSuccess:
|
||||
if price := tgSuggestedPostPrice(action.SuggestedPostPrice); price != nil {
|
||||
return &tg.MessageActionSuggestedPostSuccess{Price: price}
|
||||
}
|
||||
return nil
|
||||
case domain.ChannelActionSuggestedPostRefund:
|
||||
return &tg.MessageActionSuggestedPostRefund{PayerInitiated: action.SuggestedPostPayerInitiated}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func tgSuggestedPostPrice(price *domain.SuggestedPostPrice) tg.StarsAmountClass {
|
||||
if price == nil {
|
||||
return nil
|
||||
}
|
||||
switch price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
return &tg.StarsAmount{Amount: price.Amount, Nanos: price.Nanos}
|
||||
case domain.SuggestedPostPriceTON:
|
||||
return &tg.StarsTonAmount{Amount: price.Amount}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,14 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// resolveMonoforumForAdmin 解析 parent_peer 指向的 monoforum 虚拟频道,并校验当前用户是其母广播频道
|
||||
// 的管理员/创建者(频道私信只有频道管理员可读/回复)。monoforum 是私有零成员频道,管理员并非其成员,
|
||||
// 故走 store 的 membership-agnostic 解析,在母频道上做授权。
|
||||
// 返回 (monoforum频道, isMonoforum, err):parent 是有效频道但非 monoforum 时返回 (零, false, nil),
|
||||
// 由调用方回退良性空响应(兼容对普通频道传 parent_peer 的被动探测);是 monoforum 但非管理员→CHAT_ADMIN_REQUIRED。
|
||||
// resolveMonoforumForAdmin 解析 TDesktop messages.getSavedDialogs/getSavedHistory 的 parent_peer,
|
||||
// 并校验当前用户可管理母广播频道的 Direct Messages。TDesktop 的 SavedSublist 实际会把
|
||||
// parentChat()->input() 作为 parent_peer;根据客户端 materialize 路径,它既可能是 monoforum
|
||||
// 虚拟频道,也可能是与之关联的母广播频道。因此这里把两种 wire peer 归一到同一个 monoforum,
|
||||
// 授权仍只认母频道的 creator / ManageDirectMessages,绝不能把普通 admin 放进管理者视图。
|
||||
//
|
||||
// 返回 (monoforum频道, isMonoforum, err):parent 是有效但未关联 Direct Messages 的普通频道时
|
||||
// 返回 (零, false, nil),由调用方保留良性空响应;关联频道的非管理者返回 CHAT_ADMIN_REQUIRED。
|
||||
func (r *Router) resolveMonoforumForAdmin(ctx context.Context, userID int64, parent domain.Peer) (domain.Channel, bool, error) {
|
||||
if r.deps.Channels == nil {
|
||||
return domain.Channel{}, false, notImplementedErr()
|
||||
|
|
@ -22,9 +25,30 @@ func (r *Router) resolveMonoforumForAdmin(ctx context.Context, userID int64, par
|
|||
return domain.Channel{}, false, parentPeerInvalidErr()
|
||||
}
|
||||
mono, isAdmin, err := r.deps.Channels.ResolveMonoforumSend(ctx, userID, parent.ID)
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
// TDesktop 当前的 Direct Messages subsection 会传母广播频道。只接受显式的
|
||||
// linked_monoforum 关系,不能把任意普通频道猜成 monoforum。
|
||||
views, viewErr := r.deps.Channels.GetChannels(ctx, userID, []int64{parent.ID})
|
||||
if viewErr != nil {
|
||||
return domain.Channel{}, false, internalErr()
|
||||
}
|
||||
if len(views) != 1 {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
parentChannel := views[0].Channel
|
||||
if parentChannel.ID != parent.ID || parentChannel.Deleted || parentChannel.Monoforum || parentChannel.LinkedMonoforumID == 0 {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
mono, isAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, parentChannel.LinkedMonoforumID)
|
||||
if err != nil {
|
||||
// A visible parent that advertises linked_monoforum_id but cannot resolve
|
||||
// that target violates the durable channel-link invariant. Do not disguise
|
||||
// it as an ordinary channel probe.
|
||||
return domain.Channel{}, false, internalErr()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
// 非 monoforum 频道(或不存在):非错误,交由调用方回退良性空响应。
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
return domain.Channel{}, false, internalErr()
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经
|
||||
// getSavedDialogs(parent_peer=monoforum) 看订阅者子会话列表、经 getSavedHistory 看某订阅者历史
|
||||
// (消息带 saved_peer_id);订阅者经普通 getHistory 只看自己的子会话。
|
||||
// TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经
|
||||
// getSavedDialogs/getSavedHistory 看订阅者子会话,parent_peer 同时兼容 TDesktop 实际发送的
|
||||
// 母广播频道和虚拟 monoforum;订阅者经普通 getHistory 只看自己的子会话。
|
||||
func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
@ -64,6 +64,7 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
t.Fatalf("get monoforum: %v", err)
|
||||
}
|
||||
monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash}
|
||||
parentInput := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||
|
||||
// TDesktop 点 Direct Messages 入口会先按 monoforum peer 拉普通 channel history。
|
||||
// 主历史只应返回 monoforum 自身的 service messages,不能混入 saved_peer 子会话消息。
|
||||
|
|
@ -129,7 +130,8 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
|
||||
// 管理员看私信列表。
|
||||
dreq := &tg.MessagesGetSavedDialogsRequest{}
|
||||
dreq.SetParentPeer(monoInput)
|
||||
// TDesktop SavedSublist::loadAround() 的 parentChat()->input() 是母广播频道。
|
||||
dreq.SetParentPeer(parentInput)
|
||||
dres, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, owner.ID), dreq)
|
||||
if err != nil {
|
||||
t.Fatalf("getSavedDialogs(monoforum): %v", err)
|
||||
|
|
@ -160,7 +162,7 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
|
||||
// 管理员看某订阅者会话历史。
|
||||
hreq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}}
|
||||
hreq.SetParentPeer(monoInput)
|
||||
hreq.SetParentPeer(parentInput)
|
||||
hres, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), hreq)
|
||||
if err != nil {
|
||||
t.Fatalf("getSavedHistory(monoforum): %v", err)
|
||||
|
|
@ -194,6 +196,18 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
t.Fatalf("saved_peer_id = %#v, want sub %d", sp, sub.ID)
|
||||
}
|
||||
|
||||
// 虚拟 monoforum peer 仍是合法的等价入口,两个 parent 不能落到不同数据集。
|
||||
directMonoReq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}}
|
||||
directMonoReq.SetParentPeer(monoInput)
|
||||
directMonoRes, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), directMonoReq)
|
||||
if err != nil {
|
||||
t.Fatalf("getSavedHistory(direct monoforum): %v", err)
|
||||
}
|
||||
directMonoSlice, ok := directMonoRes.(*tg.MessagesMessagesSlice)
|
||||
if !ok || len(directMonoSlice.Messages) != 1 {
|
||||
t.Fatalf("getSavedHistory(direct monoforum) = %#v, want same single-message topic", directMonoRes)
|
||||
}
|
||||
|
||||
// 非管理员(订阅者本人)经管理员入口看列表被拒。
|
||||
if _, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, sub.ID), dreq); err == nil {
|
||||
t.Fatalf("non-admin getSavedDialogs(monoforum) = nil err, want denied")
|
||||
|
|
|
|||
|
|
@ -91,6 +91,9 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
|
|||
registerRPC[*tg.MessagesSendMessageRequest](d, tlprofile.SemanticMethodMessagesSendMessage, func(ctx context.Context, layerRequest *tg.MessagesSendMessageRequest) (any, error) {
|
||||
return r.onMessagesSendMessage(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.MessagesToggleSuggestedPostApprovalRequest](d, tlprofile.SemanticMethodMessagesToggleSuggestedPostApproval, func(ctx context.Context, layerRequest *tg.MessagesToggleSuggestedPostApprovalRequest) (any, error) {
|
||||
return r.onMessagesToggleSuggestedPostApproval(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.MessagesForwardMessagesRequest](d, tlprofile.SemanticMethodMessagesForwardMessages, func(ctx context.Context, layerRequest *tg.MessagesForwardMessagesRequest) (any, error) {
|
||||
return r.onMessagesForwardMessages(ctx, layerRequest)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -13,6 +18,71 @@ const (
|
|||
maxSuggestedPostNanoTON int64 = 10_000_000_000_000
|
||||
)
|
||||
|
||||
const (
|
||||
minSuggestedPostScheduleDelay = 5 * 60
|
||||
maxSuggestedPostScheduleDelay = 31 * 24 * 60 * 60
|
||||
maxSuggestedPostRejectComment = 1024
|
||||
)
|
||||
|
||||
type suggestedPostApprovalService interface {
|
||||
ToggleSuggestedPostApproval(context.Context, domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error)
|
||||
ProcessSuggestedPostLifecycle(context.Context, domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error)
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesToggleSuggestedPostApproval(ctx context.Context, req *tg.MessagesToggleSuggestedPostApprovalRequest) (tg.UpdatesClass, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if req == nil || req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
comment, hasComment := req.GetRejectComment()
|
||||
if (!req.Reject && hasComment) || utf8.RuneCountInString(comment) > maxSuggestedPostRejectComment {
|
||||
return nil, tgerr400("SUGGESTED_POST_INVALID")
|
||||
}
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if peer.Type != domain.PeerTypeChannel || peer.ID == 0 {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
service, ok := r.deps.Channels.(suggestedPostApprovalService)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
scheduleDate, hasScheduleDate := req.GetScheduleDate()
|
||||
if hasScheduleDate && (req.Reject || scheduleDate < now+minSuggestedPostScheduleDelay || scheduleDate > now+maxSuggestedPostScheduleDelay) {
|
||||
return nil, scheduleDateInvalidErr()
|
||||
}
|
||||
result, err := service.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||
UserID: userID, MonoforumID: peer.ID, MessageID: req.MsgID, Reject: req.Reject,
|
||||
RejectComment: strings.TrimSpace(comment), ScheduleDate: scheduleDate, Date: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, suggestedPostApprovalErr(err)
|
||||
}
|
||||
if !result.Duplicate {
|
||||
r.enqueueSuggestedPostApprovalFanout(ctx, userID, result)
|
||||
}
|
||||
return r.suggestedPostApprovalUpdates(ctx, userID, result), nil
|
||||
}
|
||||
|
||||
func suggestedPostApprovalErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrSuggestedPostApprovalForbidden):
|
||||
return tgerr400("CHAT_ADMIN_REQUIRED")
|
||||
case errors.Is(err, domain.ErrSuggestedPostAlreadyHandled):
|
||||
return tgerr400("SUGGESTED_POST_ALREADY_HANDLED")
|
||||
case errors.Is(err, domain.ErrSuggestedPostInvalid), errors.Is(err, domain.ErrChannelInvalid), errors.Is(err, domain.ErrMessageIDInvalid):
|
||||
return tgerr400("SUGGESTED_POST_INVALID")
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
|
||||
func domainSuggestedPost(input tg.SuggestedPost, present bool) (*domain.SuggestedPost, error) {
|
||||
if !present {
|
||||
return nil, nil
|
||||
|
|
@ -30,8 +100,7 @@ func domainSuggestedPost(input tg.SuggestedPost, present bool) (*domain.Suggeste
|
|||
if price, ok := input.GetPrice(); ok {
|
||||
switch value := price.(type) {
|
||||
case *tg.StarsAmount:
|
||||
if value == nil || value.Amount < minSuggestedPostStars || value.Amount > maxSuggestedPostStars ||
|
||||
value.Nanos < 0 || value.Nanos >= 1_000_000_000 || value.Amount == maxSuggestedPostStars && value.Nanos != 0 {
|
||||
if value == nil || value.Amount < minSuggestedPostStars || value.Amount > maxSuggestedPostStars || value.Nanos != 0 {
|
||||
return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID")
|
||||
}
|
||||
out.Price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: value.Amount, Nanos: value.Nanos}
|
||||
|
|
|
|||
158
internal/rpc/messages_suggested_post_rpc_test.go
Normal file
158
internal/rpc/messages_suggested_post_rpc_test.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestMessagesToggleSuggestedPostApprovalRegisteredAndProjectsLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 101, Phone: "15551110001", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subscriber, err := users.Create(ctx, domain.User{AccessHash: 102, Phone: "15551110002", FirstName: "Subscriber"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelsStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelsStore)
|
||||
created, err := channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "Suggested", Broadcast: true, Date: 1_700_000_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, err := channelsStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mono, err := channelsStore.GetChannelByID(ctx, enabled.Channel.LinkedMonoforumID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved := domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID}
|
||||
suggestion, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 91, Message: "RPC suggestion", SuggestedPost: &domain.SuggestedPost{}, Date: 1_700_000_100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := New(Config{}, Deps{Users: appusers.NewService(users), Channels: channels}, zaptest.NewLogger(t), clock.System)
|
||||
req := &tg.MessagesToggleSuggestedPostApprovalRequest{Peer: &tg.InputPeerChannel{ChannelID: mono.ID, AccessHash: mono.AccessHash}, MsgID: suggestion.Message.ID}
|
||||
var raw bin.Buffer
|
||||
if err := req.Encode(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := router.Dispatch(WithLayer(WithUserID(ctx, owner.ID), 228), [8]byte{}, 0, &raw)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch toggleSuggestedPostApproval: %v", err)
|
||||
}
|
||||
updates, ok := response.(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("response=%T, want *tg.Updates", response)
|
||||
}
|
||||
var edited, approval, published bool
|
||||
for _, update := range updates.Updates {
|
||||
switch item := update.(type) {
|
||||
case *tg.UpdateEditChannelMessage:
|
||||
message, ok := item.Message.(*tg.Message)
|
||||
if ok && message.ID == suggestion.Message.ID {
|
||||
post, present := message.GetSuggestedPost()
|
||||
edited = present && post.GetAccepted()
|
||||
}
|
||||
case *tg.UpdateNewChannelMessage:
|
||||
switch message := item.Message.(type) {
|
||||
case *tg.MessageService:
|
||||
action, ok := message.Action.(*tg.MessageActionSuggestedPostApproval)
|
||||
if ok {
|
||||
scheduleDate, hasScheduleDate := action.GetScheduleDate()
|
||||
approval = !action.Rejected && !action.BalanceTooLow && hasScheduleDate && scheduleDate > 0
|
||||
}
|
||||
if savedPeer, present := message.GetSavedPeerID(); !present {
|
||||
t.Fatalf("approval service missing saved_peer_id")
|
||||
} else if peer, ok := savedPeer.(*tg.PeerUser); !ok || peer.UserID != subscriber.ID {
|
||||
t.Fatalf("approval saved_peer=%#v", savedPeer)
|
||||
}
|
||||
case *tg.Message:
|
||||
published = message.PeerID.(*tg.PeerChannel).ChannelID == created.Channel.ID && message.Post && message.Message == "RPC suggestion"
|
||||
}
|
||||
}
|
||||
}
|
||||
if !edited || !approval || !published {
|
||||
t.Fatalf("updates missing edit/approval/publish: %#v", updates.Updates)
|
||||
}
|
||||
|
||||
var retryRaw bin.Buffer
|
||||
if err := req.Encode(&retryRaw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retry, err := router.Dispatch(WithLayer(WithUserID(ctx, owner.ID), 227), [8]byte{}, 0, &retryRaw)
|
||||
if err != nil {
|
||||
t.Fatalf("layer 227 retry: %v", err)
|
||||
}
|
||||
got, ok := retry.(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("layer 227 response=%T", retry)
|
||||
}
|
||||
// Duplicate replay returns the persisted approval + published update to
|
||||
// the caller but is never fanned out again.
|
||||
if len(got.Updates) != 3 {
|
||||
t.Fatalf("layer 227 duplicate updates=%d, want 3", len(got.Updates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostTLProjectionSeparatesSuggestionAndPublishedPaymentFlags(t *testing.T) {
|
||||
original := domain.ChannelMessage{ChannelID: 10, ID: 1, SenderUserID: 20, From: domain.Peer{Type: domain.PeerTypeUser, ID: 20}, SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 20}, Date: 100, Body: "proposal", SuggestedPost: &domain.SuggestedPost{Accepted: true, Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}}
|
||||
proposal := tgChannelMessage(20, original).(*tg.Message)
|
||||
if _, present := proposal.GetSuggestedPost(); !present || proposal.GetPaidSuggestedPostStars() {
|
||||
t.Fatalf("proposal flags=%+v", proposal)
|
||||
}
|
||||
published := original
|
||||
published.ChannelID, published.ID, published.Post, published.SavedPeer = 11, 2, true, domain.Peer{}
|
||||
post := tgChannelMessage(20, published).(*tg.Message)
|
||||
if !post.GetPaidSuggestedPostStars() {
|
||||
t.Fatalf("published Stars post missing paid flag")
|
||||
}
|
||||
if _, present := post.GetSuggestedPost(); present {
|
||||
t.Fatalf("published post leaked suggested_post")
|
||||
}
|
||||
published.SuggestedPost.Price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceTON, Amount: 10_000_000}
|
||||
ton := tgChannelMessage(20, published).(*tg.Message)
|
||||
if !ton.GetPaidSuggestedPostTon() || ton.GetPaidSuggestedPostStars() {
|
||||
t.Fatalf("published TON flags=%+v", ton)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostApprovalScheduleDateSurvivesExactProfiles(t *testing.T) {
|
||||
action := tgChannelMessageAction(domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionSuggestedPostApproval,
|
||||
SuggestedPostScheduleDate: 1_700_000_200,
|
||||
})
|
||||
for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} {
|
||||
wire := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, action, wire); err != nil {
|
||||
t.Fatalf("encode Layer %d approval action: %v", profile, err)
|
||||
}
|
||||
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Copy()}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode Layer %d approval action: %v", profile, err)
|
||||
}
|
||||
decoded, ok := decodedObject.(*tg.MessageActionSuggestedPostApproval)
|
||||
if !ok {
|
||||
t.Fatalf("decode Layer %d approval action = %T", profile, decodedObject)
|
||||
}
|
||||
date, present := decoded.GetScheduleDate()
|
||||
if !present || date != 1_700_000_200 {
|
||||
t.Fatalf("Layer %d approval date=%d/%v, want 1700000200/true", profile, date, present)
|
||||
}
|
||||
}
|
||||
}
|
||||
65
internal/rpc/messages_suggested_post_updates.go
Normal file
65
internal/rpc/messages_suggested_post_updates.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (r *Router) suggestedPostApprovalUpdates(ctx context.Context, viewerUserID int64, result domain.ToggleSuggestedPostApprovalResult) *tg.Updates {
|
||||
updates := make([]tg.UpdateClass, 0, 4)
|
||||
if result.OriginalEvent.Pts > 0 {
|
||||
if update := tgChannelUpdate(viewerUserID, result.OriginalEvent); update != nil {
|
||||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
if result.ServiceEvent.Pts > 0 {
|
||||
if update := tgChannelUpdate(viewerUserID, result.ServiceEvent); update != nil {
|
||||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
if result.Published != nil && result.Published.Event.Pts > 0 {
|
||||
if update := tgChannelUpdate(viewerUserID, result.Published.Event); update != nil {
|
||||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
if result.PayerStarsBalance != nil && result.PayerStarsBalance.UserID == viewerUserID {
|
||||
updates = append(updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.PayerStarsBalance.Balance}})
|
||||
}
|
||||
chats := r.monoforumChats(ctx, viewerUserID, result.Monoforum)
|
||||
if result.Parent.ID != 0 {
|
||||
chats = appendUniqueTGChats(chats, tgChannelChatMin(viewerUserID, result.Parent))
|
||||
}
|
||||
messages := make([]domain.ChannelMessage, 0, 3)
|
||||
if result.OriginalMessage.ID != 0 {
|
||||
messages = append(messages, result.OriginalMessage)
|
||||
}
|
||||
if result.ServiceMessage.ID != 0 {
|
||||
messages = append(messages, result.ServiceMessage)
|
||||
}
|
||||
if result.Published != nil {
|
||||
messages = append(messages, result.Published.Message)
|
||||
}
|
||||
return &tg.Updates{
|
||||
Updates: updates,
|
||||
Chats: chats,
|
||||
Users: r.monoforumSubscriberUsers(ctx, viewerUserID, []domain.MonoforumDialog{{SavedPeer: result.SavedPeer}}, messages),
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) enqueueSuggestedPostApprovalFanout(ctx context.Context, originUserID int64, result domain.ToggleSuggestedPostApprovalResult) {
|
||||
monoOnly := result
|
||||
monoOnly.Published = nil
|
||||
nudge := max(result.OriginalEvent.Pts, result.ServiceEvent.Pts)
|
||||
if nudge > 0 {
|
||||
r.enqueueChannelFanout(ctx, channelFanoutExplicit, originUserID, result.Monoforum.ID, nudge, result.Recipients, func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
|
||||
return r.suggestedPostApprovalUpdates(bgCtx, viewerUserID, monoOnly)
|
||||
})
|
||||
}
|
||||
if result.Published != nil && result.Published.Event.Pts > 0 {
|
||||
r.enqueueChannelMessageFanout(ctx, originUserID, *result.Published, nil)
|
||||
}
|
||||
}
|
||||
59
internal/rpc/suggested_post_dispatcher.go
Normal file
59
internal/rpc/suggested_post_dispatcher.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// SuggestedPostDispatcher publishes scheduled suggestions and resolves paid
|
||||
// escrow after the minimum live age (or refunds it when the post is deleted).
|
||||
// Store-side row locks make multiple server instances safe.
|
||||
type SuggestedPostDispatcher struct {
|
||||
router *Router
|
||||
log *zap.Logger
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
func NewSuggestedPostDispatcher(router *Router, log *zap.Logger) *SuggestedPostDispatcher {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &SuggestedPostDispatcher{router: router, log: log, interval: time.Second, batch: 50}
|
||||
}
|
||||
|
||||
func (d *SuggestedPostDispatcher) Run(ctx context.Context) {
|
||||
if d == nil || d.router == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(d.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
d.DispatchOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SuggestedPostDispatcher) DispatchOnce(ctx context.Context) bool {
|
||||
service, ok := d.router.deps.Channels.(suggestedPostApprovalService)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
results, err := service.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: int(d.router.clock.Now().Unix()), Limit: d.batch})
|
||||
if err != nil {
|
||||
d.log.Warn("process suggested post lifecycle", zap.Error(err))
|
||||
return false
|
||||
}
|
||||
for _, result := range results {
|
||||
d.router.enqueueSuggestedPostApprovalFanout(ctx, 0, result)
|
||||
}
|
||||
return len(results) > 0
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
421
internal/store/memory/channel_suggested_post.go
Normal file
421
internal/store/memory/channel_suggested_post.go
Normal 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
|
||||
}
|
||||
276
internal/store/memory/channel_suggested_post_test.go
Normal file
276
internal/store/memory/channel_suggested_post_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -267,7 +267,7 @@ WHERE i.user_id = $1
|
|||
AND NOT i.deleted
|
||||
AND i.role IN ('creator','admin')
|
||||
AND pm.status = 'active'
|
||||
AND pm.role IN ('creator','admin')
|
||||
AND (pm.role = 'creator' OR (pm.role = 'admin' AND COALESCE((pm.admin_rights->>'ManageDirectMessages')::boolean, false)))
|
||||
ORDER BY COALESCE(d.pinned, false) DESC,
|
||||
COALESCE(d.pinned_order, 0) DESC,
|
||||
COALESCE(top_msg.message_date, d.top_message_date, c.date) DESC,
|
||||
|
|
|
|||
|
|
@ -310,7 +310,8 @@ WITH visible_channels AS (
|
|||
WHERE mono.monoforum AND NOT mono.deleted
|
||||
AND (EXISTS (
|
||||
SELECT 1 FROM channel_members admin
|
||||
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin')
|
||||
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active'
|
||||
AND (admin.role = 'creator' OR (admin.role = 'admin' AND COALESCE((admin.admin_rights->>'ManageDirectMessages')::boolean, false)))
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM channel_messages message
|
||||
WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted
|
||||
|
|
@ -355,7 +356,8 @@ WITH visible_channels AS (
|
|||
WHERE mono.monoforum AND NOT mono.deleted
|
||||
AND (EXISTS (
|
||||
SELECT 1 FROM channel_members admin
|
||||
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin')
|
||||
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active'
|
||||
AND (admin.role = 'creator' OR (admin.role = 'admin' AND COALESCE((admin.admin_rights->>'ManageDirectMessages')::boolean, false)))
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM channel_messages message
|
||||
WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted
|
||||
|
|
|
|||
|
|
@ -454,7 +454,7 @@ func (s *ChannelStore) monoforumAdminPreview(ctx context.Context, db sqlcgen.DBT
|
|||
}
|
||||
return domain.ChannelMember{}, domain.Channel{}, false, err
|
||||
}
|
||||
if !isChannelAdmin(parentMember) {
|
||||
if !parentMember.CanManageDirectMessages() {
|
||||
return domain.ChannelMember{}, domain.Channel{}, false, nil
|
||||
}
|
||||
return syntheticMonoforumAdminMember(mono, parentMember), parent, true, nil
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int6
|
|||
base := "channel_id = $1 AND NOT deleted"
|
||||
extraChannels := []domain.Channel(nil)
|
||||
if channel.Monoforum {
|
||||
if isChannelAdmin(member) {
|
||||
if member.CanManageDirectMessages() {
|
||||
base += " AND saved_peer_id = 0"
|
||||
} else {
|
||||
baseArgs = append(baseArgs, viewerUserID)
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ FOR SHARE OF m, p`, channel.ID).Scan(
|
|||
if parentMemberErr != nil && !errors.Is(parentMemberErr, domain.ErrChannelPrivate) {
|
||||
return domain.SendChannelMessageResult{}, parentMemberErr
|
||||
}
|
||||
isAdmin := parentMemberErr == nil && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember)
|
||||
isAdmin := parentMemberErr == nil && parentMember.CanManageDirectMessages()
|
||||
if req.SenderUserID != req.SavedPeer.ID && !isAdmin {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
|
|
@ -237,7 +237,12 @@ SELECT EXISTS (
|
|||
return domain.SendChannelMessageResult{}, fmt.Errorf("update monoforum top: %w", err)
|
||||
}
|
||||
recipients := []int64{req.SavedPeer.ID}
|
||||
rows, err := tx.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' AND role IN ('creator', 'admin') ORDER BY user_id`, parent.ID)
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT user_id
|
||||
FROM channel_members
|
||||
WHERE channel_id = $1 AND status = 'active'
|
||||
AND (role = 'creator' OR (role = 'admin' AND COALESCE((admin_rights->>'ManageDirectMessages')::boolean, false)))
|
||||
ORDER BY user_id`, parent.ID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("list monoforum recipients: %w", err)
|
||||
}
|
||||
|
|
@ -313,7 +318,7 @@ func (s *ChannelStore) ListMonoforumHistory(ctx context.Context, filter domain.M
|
|||
}
|
||||
|
||||
// ResolveMonoforumSend 按 id 取 monoforum 频道(不要求调用者是 monoforum 成员),并返回调用者是否为
|
||||
// 其母广播频道的创建者/管理员。非 monoforum/不存在 → ErrChannelInvalid。
|
||||
// 其母广播频道 Direct Messages 管理者。非 monoforum/不存在 → ErrChannelInvalid。
|
||||
func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error) {
|
||||
if viewerUserID == 0 || monoforumID == 0 {
|
||||
return domain.Channel{}, false, domain.ErrChannelInvalid
|
||||
|
|
@ -330,8 +335,7 @@ func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, m
|
|||
}
|
||||
isAdmin := false
|
||||
if _, member, memberErr := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); memberErr == nil {
|
||||
isAdmin = member.Status == domain.ChannelMemberActive &&
|
||||
(member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin)
|
||||
isAdmin = member.CanManageDirectMessages()
|
||||
} else if !errors.Is(memberErr, domain.ErrChannelPrivate) {
|
||||
return domain.Channel{}, false, memberErr
|
||||
}
|
||||
|
|
|
|||
708
internal/store/postgres/channel_suggested_post.go
Normal file
708
internal/store/postgres/channel_suggested_post.go
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const suggestedPostSettlementAge = 24 * 60 * 60
|
||||
|
||||
type persistedSuggestedPostApproval struct {
|
||||
monoforumID, parentID, actorID, payerID int64
|
||||
messageID, scheduleDate, approvalServiceID, publishedMessageID, settlementDue, finalServiceID int
|
||||
state domain.SuggestedPostLifecycleState
|
||||
price *domain.SuggestedPostPrice
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ToggleSuggestedPostApproval(ctx 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 = nowUnix()
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("toggle suggested post: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("begin toggle suggested post: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
mono, err := getChannelByID(ctx, tx, req.MonoforumID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
if mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID == 0 {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
parent, err := getChannelByID(ctx, tx, mono.LinkedMonoforumID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
if parent.Deleted || !parent.Broadcast || parent.LinkedMonoforumID != mono.ID {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `SELECT 1 FROM channel_messages WHERE channel_id=$1 AND id=$2 FOR UPDATE`, mono.ID, req.MessageID); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
original, err := s.getChannelMessage(ctx, tx, mono.ID, req.MessageID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrMessageIDInvalid) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
if original.Deleted || original.SavedPeer.Type != domain.PeerTypeUser || original.SavedPeer.ID == 0 || original.SuggestedPost == nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
fromSubscriber := original.From.Type == domain.PeerTypeUser
|
||||
manager := domain.ChannelMember{}
|
||||
if fromSubscriber {
|
||||
manager, err = s.getChannelMember(ctx, tx, parent.ID, req.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelPrivate) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
|
||||
}
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
if !manager.CanManageDirectMessages() || (!req.Reject && !manager.CanPostChannelMessages()) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
|
||||
}
|
||||
} else if req.UserID != original.SavedPeer.ID {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
|
||||
}
|
||||
|
||||
existing, found, err := loadSuggestedPostApprovalTx(ctx, tx, mono.ID, original.ID, true)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
if found && existing.state != domain.SuggestedPostStateBalanceLow {
|
||||
result, err := s.loadSuggestedPostResultTx(ctx, tx, existing, true)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
committed = true
|
||||
return result, nil
|
||||
}
|
||||
if original.SuggestedPost.Accepted || original.SuggestedPost.Rejected {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostAlreadyHandled
|
||||
}
|
||||
price := cloneSuggestedPricePG(original.SuggestedPost.Price)
|
||||
if price != nil && price.Kind == domain.SuggestedPostPriceStars && price.Nanos != 0 {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
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, err := monoforumManagerRecipientsTx(ctx, tx, parent.ID, original.SavedPeer.ID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result := domain.ToggleSuggestedPostApprovalResult{Monoforum: mono, Parent: parent, SavedPeer: original.SavedPeer, Recipients: recipients}
|
||||
|
||||
if req.Reject {
|
||||
original.SuggestedPost.Accepted, original.SuggestedPost.Rejected = false, true
|
||||
original, result.OriginalEvent, err = s.persistSuggestedPostEditTx(ctx, tx, original, req.UserID, req.Date)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result.OriginalMessage = original
|
||||
result.ServiceMessage, result.ServiceEvent, err = s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, req.UserID, fromSubscriber && manager.CanManageDirectMessages(), original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostRejected: true,
|
||||
SuggestedPostRejectComment: strings.TrimSpace(req.RejectComment), SuggestedPostPrice: price,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result.State = domain.SuggestedPostStateRejected
|
||||
if err := upsertSuggestedPostApprovalTx(ctx, tx, persistedSuggestedPostApproval{monoforumID: mono.ID, messageID: original.ID, parentID: parent.ID, actorID: req.UserID, payerID: original.SavedPeer.ID, state: result.State, price: price, scheduleDate: scheduleDate, approvalServiceID: result.ServiceMessage.ID}, req.Date); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
} else {
|
||||
stars, ton, enough, err := reserveSuggestedPostPaymentTx(ctx, tx, original.SavedPeer.ID, parent.ID, price, req.Date)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
|
||||
if !enough {
|
||||
if found {
|
||||
result, err = s.loadSuggestedPostResultTx(ctx, tx, existing, true)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
|
||||
} else {
|
||||
result.ServiceMessage, result.ServiceEvent, err = s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, req.UserID, fromSubscriber && manager.CanManageDirectMessages(), original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostBalanceTooLow: true,
|
||||
SuggestedPostScheduleDate: scheduleDate, SuggestedPostPrice: price,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result.State = domain.SuggestedPostStateBalanceLow
|
||||
if err := upsertSuggestedPostApprovalTx(ctx, tx, persistedSuggestedPostApproval{monoforumID: mono.ID, messageID: original.ID, parentID: parent.ID, actorID: req.UserID, payerID: original.SavedPeer.ID, state: result.State, price: price, scheduleDate: scheduleDate, approvalServiceID: result.ServiceMessage.ID}, req.Date); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
effectivePublishDate := scheduleDate
|
||||
if effectivePublishDate == 0 {
|
||||
// TDesktop's "Publish Now" request has no schedule_date flag, but
|
||||
// its approval service renderer always expects an absolute date.
|
||||
effectivePublishDate = req.Date
|
||||
}
|
||||
original.SuggestedPost.Accepted, original.SuggestedPost.Rejected, original.SuggestedPost.ScheduleDate = true, false, effectivePublishDate
|
||||
original, result.OriginalEvent, err = s.persistSuggestedPostEditTx(ctx, tx, original, req.UserID, req.Date)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result.OriginalMessage = original
|
||||
result.ServiceMessage, result.ServiceEvent, err = s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, req.UserID, fromSubscriber && manager.CanManageDirectMessages(), original.SavedPeer, original.ID, req.Date, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostApproval, SuggestedPostScheduleDate: effectivePublishDate, SuggestedPostPrice: price})
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
record := persistedSuggestedPostApproval{monoforumID: mono.ID, messageID: original.ID, parentID: parent.ID, actorID: req.UserID, payerID: original.SavedPeer.ID, state: domain.SuggestedPostStateScheduled, price: price, scheduleDate: effectivePublishDate, approvalServiceID: result.ServiceMessage.ID}
|
||||
if effectivePublishDate <= req.Date {
|
||||
published, publishErr := s.publishSuggestedPostTx(ctx, tx, parent, original, req.UserID, req.Date)
|
||||
if publishErr != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, publishErr
|
||||
}
|
||||
result.Published = &published
|
||||
record.publishedMessageID = published.Message.ID
|
||||
if price == nil {
|
||||
record.state = domain.SuggestedPostStateCompleted
|
||||
} else {
|
||||
record.state = domain.SuggestedPostStatePublished
|
||||
record.settlementDue = req.Date + suggestedPostSettlementAge
|
||||
}
|
||||
}
|
||||
result.State = record.state
|
||||
if err := upsertSuggestedPostApprovalTx(ctx, tx, record, req.Date); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("commit toggle suggested post: %w", err)
|
||||
}
|
||||
committed = true
|
||||
result.Monoforum, err = getChannelByID(ctx, s.db, mono.ID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("reload suggested post monoforum after commit: %w", err)
|
||||
}
|
||||
result.Parent, err = getChannelByID(ctx, s.db, parent.ID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("reload suggested post parent after commit: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) persistSuggestedPostEditTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMessage, actor int64, date int) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) {
|
||||
pts, err := s.reserveChannelPts(ctx, tx, msg.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
encoded, err := marshalJSON(msg.SuggestedPost, "{}")
|
||||
if err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
msg.Pts = pts
|
||||
if _, err := tx.Exec(ctx, `UPDATE channel_messages SET suggested_post=$3, pts=$4, updated_at=now() WHERE channel_id=$1 AND id=$2 AND NOT deleted`, msg.ChannelID, msg.ID, encoded, pts); err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("update suggested post message: %w", err)
|
||||
}
|
||||
event := domain.ChannelUpdateEvent{ChannelID: msg.ChannelID, Type: domain.ChannelUpdateEditMessage, Pts: pts, PtsCount: 1, Date: date, Message: msg, SenderUserID: actor}
|
||||
if err := insertChannelEventTx(ctx, tx, event); err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
return msg, event, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) insertSuggestedPostServiceTx(ctx context.Context, tx pgx.Tx, mono, parent domain.Channel, actor int64, fromChannel bool, saved domain.Peer, replyID, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) {
|
||||
msgID, err := s.msgIDs.NextChannelMessageID(ctx, mono.ID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
pts, err := s.reserveChannelPts(ctx, tx, mono.ID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
from := domain.Peer{Type: domain.PeerTypeUser, ID: actor}
|
||||
if fromChannel {
|
||||
from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}
|
||||
}
|
||||
msg := domain.ChannelMessage{ChannelID: mono.ID, ID: msgID, SenderUserID: actor, From: from, SavedPeer: saved, Date: date, ReplyTo: &domain.MessageReply{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: mono.ID}, MessageID: replyID}, Action: &action, Pts: pts}
|
||||
event := domain.ChannelUpdateEvent{ChannelID: mono.ID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: date, Message: msg, SenderUserID: actor}
|
||||
if err := insertChannelMessageTx(ctx, tx, msg); err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
if err := insertChannelEventTx(ctx, tx, event); err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id=$2, pts=$3, updated_at=now() WHERE id=$1`, mono.ID, msgID, pts); err != nil {
|
||||
return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err
|
||||
}
|
||||
return msg, event, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) publishSuggestedPostTx(ctx context.Context, tx pgx.Tx, parent domain.Channel, original domain.ChannelMessage, actor int64, date int) (domain.SendChannelMessageResult, error) {
|
||||
msgID, err := s.msgIDs.NextChannelMessageID(ctx, parent.ID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
pts, err := s.reserveChannelPts(ctx, tx, parent.ID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
msg := original
|
||||
msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID = parent.ID, msgID, 0, actor
|
||||
msg.From, msg.SendAs, msg.SavedPeer, msg.Date, msg.EditDate, msg.Post = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}, nil, domain.Peer{}, date, 0, true
|
||||
msg.ReplyTo, msg.PaidMessageStars, msg.Pts, msg.Deleted = nil, 0, pts, false
|
||||
event := domain.ChannelUpdateEvent{ChannelID: parent.ID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: date, Message: msg, SenderUserID: actor}
|
||||
if err := insertChannelMessageTx(ctx, tx, msg); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
if err := insertChannelEventTx(ctx, tx, event); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id=$2, pts=$3, updated_at=now() WHERE id=$1`, parent.ID, msgID, pts); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
recipients, err := s.listActiveChannelMemberIDs(ctx, tx, parent.ID, 0)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
parent.TopMessageID, parent.Pts = msgID, pts
|
||||
return domain.SendChannelMessageResult{Channel: parent, Message: msg, Event: event, Recipients: recipients}, nil
|
||||
}
|
||||
|
||||
func reserveSuggestedPostPaymentTx(ctx context.Context, tx pgx.Tx, payerID, parentID int64, price *domain.SuggestedPostPrice, date int) (*domain.StarsBalance, *int64, bool, error) {
|
||||
if price == nil {
|
||||
return nil, nil, true, nil
|
||||
}
|
||||
switch price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
if price.Nanos != 0 || price.Amount <= 0 {
|
||||
return nil, nil, false, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
balance := domain.StarsBalance{UserID: payerID}
|
||||
err := tx.QueryRow(ctx, `SELECT balance,granted FROM stars_balances WHERE user_id=$1 FOR UPDATE`, payerID).Scan(&balance.Balance, &balance.Granted)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return &balance, nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
if balance.Balance < price.Amount {
|
||||
return &balance, nil, false, nil
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance=balance-$2,updated_at=now() WHERE user_id=$1 RETURNING balance`, payerID, price.Amount).Scan(&balance.Balance); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
if err := insertStarsTxn(ctx, tx, payerID, -price.Amount, domain.StarsReasonSuggestedPost, domain.Peer{Type: domain.PeerTypeChannel, ID: parentID}, date, "Suggested post escrow", ""); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
return &balance, nil, true, nil
|
||||
case domain.SuggestedPostPriceTON:
|
||||
var balance int64
|
||||
err := tx.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id=$1 FOR UPDATE`, payerID).Scan(&balance)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, &balance, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
if balance < price.Amount {
|
||||
return nil, &balance, false, nil
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `UPDATE ton_balances SET balance_nanoton=balance_nanoton-$2,updated_at=now() WHERE user_id=$1 RETURNING balance_nanoton`, payerID, price.Amount).Scan(&balance); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,peer_type,peer_id,date) VALUES($1,$2,$3,'channel',$4,$5)`, payerID, -price.Amount, string(domain.StarsReasonSuggestedPost), parentID, date); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
return nil, &balance, true, nil
|
||||
default:
|
||||
return nil, nil, false, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func monoforumManagerRecipientsTx(ctx context.Context, tx pgx.Tx, parentID, subscriberID int64) ([]int64, error) {
|
||||
rows, err := tx.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id=$1 AND status='active' AND (role='creator' OR (role='admin' AND COALESCE((admin_rights->>'ManageDirectMessages')::boolean,false))) ORDER BY user_id`, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := []int64{subscriberID}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return uniqueChannelUserIDs(ids, 0), rows.Err()
|
||||
}
|
||||
|
||||
func upsertSuggestedPostApprovalTx(ctx context.Context, tx pgx.Tx, row persistedSuggestedPostApproval, date int) error {
|
||||
kind, amount, nanos := "", int64(0), 0
|
||||
if row.price != nil {
|
||||
kind, amount, nanos = string(row.price.Kind), row.price.Amount, row.price.Nanos
|
||||
}
|
||||
_, err := tx.Exec(ctx, `INSERT INTO suggested_post_approvals(monoforum_id,suggestion_message_id,parent_channel_id,actor_user_id,payer_user_id,state,price_kind,price_amount,price_nanos,schedule_date,approval_service_message_id,published_message_id,settlement_due,final_service_message_id,created_at,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$15)
|
||||
ON CONFLICT(monoforum_id,suggestion_message_id) DO UPDATE SET actor_user_id=EXCLUDED.actor_user_id,state=EXCLUDED.state,price_kind=EXCLUDED.price_kind,price_amount=EXCLUDED.price_amount,price_nanos=EXCLUDED.price_nanos,schedule_date=EXCLUDED.schedule_date,approval_service_message_id=EXCLUDED.approval_service_message_id,published_message_id=EXCLUDED.published_message_id,settlement_due=EXCLUDED.settlement_due,final_service_message_id=EXCLUDED.final_service_message_id,updated_at=EXCLUDED.updated_at`,
|
||||
row.monoforumID, row.messageID, row.parentID, row.actorID, row.payerID, string(row.state), kind, amount, nanos, row.scheduleDate, row.approvalServiceID, row.publishedMessageID, row.settlementDue, row.finalServiceID, date)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadSuggestedPostApprovalTx(ctx context.Context, tx pgx.Tx, monoID int64, messageID int, lock bool) (persistedSuggestedPostApproval, bool, error) {
|
||||
q := `SELECT parent_channel_id,actor_user_id,payer_user_id,state,price_kind,price_amount,price_nanos,schedule_date,approval_service_message_id,published_message_id,settlement_due,final_service_message_id FROM suggested_post_approvals WHERE monoforum_id=$1 AND suggestion_message_id=$2`
|
||||
if lock {
|
||||
q += ` FOR UPDATE`
|
||||
}
|
||||
var row persistedSuggestedPostApproval
|
||||
row.monoforumID, row.messageID = monoID, messageID
|
||||
var state, kind string
|
||||
var amount int64
|
||||
var nanos int
|
||||
err := tx.QueryRow(ctx, q, monoID, messageID).Scan(&row.parentID, &row.actorID, &row.payerID, &state, &kind, &amount, &nanos, &row.scheduleDate, &row.approvalServiceID, &row.publishedMessageID, &row.settlementDue, &row.finalServiceID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return row, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return row, false, err
|
||||
}
|
||||
row.state = domain.SuggestedPostLifecycleState(state)
|
||||
if kind != "" {
|
||||
row.price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceKind(kind), Amount: amount, Nanos: nanos}
|
||||
}
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) loadSuggestedPostResultTx(ctx context.Context, tx pgx.Tx, row persistedSuggestedPostApproval, duplicate bool) (domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
mono, err := getChannelByID(ctx, tx, row.monoforumID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
parent, err := getChannelByID(ctx, tx, row.parentID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
original, err := s.getChannelMessage(ctx, tx, row.monoforumID, row.messageID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
recipients, err := monoforumManagerRecipientsTx(ctx, tx, row.parentID, row.payerID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, err
|
||||
}
|
||||
result := domain.ToggleSuggestedPostApprovalResult{Monoforum: mono, Parent: parent, SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: row.payerID}, State: row.state, OriginalMessage: original, Recipients: recipients, Duplicate: duplicate}
|
||||
if original.SuggestedPost != nil && (original.SuggestedPost.Accepted || original.SuggestedPost.Rejected) && original.Pts > 0 {
|
||||
eventDate, senderUserID := original.Date, row.actorID
|
||||
// The event row is the exact durable replay source. Retention may have
|
||||
// pruned an old event, in which case the message snapshot still provides
|
||||
// a safe replay with its original date and lifecycle actor.
|
||||
if err := tx.QueryRow(ctx, `SELECT date,sender_user_id FROM channel_update_events WHERE channel_id=$1 AND pts=$2 AND event_type='edit_channel_message'`, row.monoforumID, original.Pts).Scan(&eventDate, &senderUserID); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, fmt.Errorf("load suggested post edit event: %w", err)
|
||||
}
|
||||
result.OriginalEvent = domain.ChannelUpdateEvent{ChannelID: row.monoforumID, Type: domain.ChannelUpdateEditMessage, Pts: original.Pts, PtsCount: 1, Date: eventDate, Message: original, SenderUserID: senderUserID}
|
||||
}
|
||||
if row.approvalServiceID > 0 {
|
||||
result.ServiceMessage, err = s.getChannelMessage(ctx, tx, row.monoforumID, row.approvalServiceID)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.ServiceEvent = domain.ChannelUpdateEvent{ChannelID: row.monoforumID, Type: domain.ChannelUpdateNewMessage, Pts: result.ServiceMessage.Pts, PtsCount: 1, Date: result.ServiceMessage.Date, Message: result.ServiceMessage, SenderUserID: row.actorID}
|
||||
}
|
||||
if row.publishedMessageID > 0 {
|
||||
msg, e := s.getChannelMessage(ctx, tx, row.parentID, row.publishedMessageID)
|
||||
if e != nil {
|
||||
return result, e
|
||||
}
|
||||
event := domain.ChannelUpdateEvent{ChannelID: row.parentID, Type: domain.ChannelUpdateNewMessage, Pts: msg.Pts, PtsCount: 1, Date: msg.Date, Message: msg, SenderUserID: row.actorID}
|
||||
result.Published = &domain.SendChannelMessageResult{Channel: parent, Message: msg, Event: event}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func cloneSuggestedPricePG(in *domain.SuggestedPostPrice) *domain.SuggestedPostPrice {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
return &out
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ProcessSuggestedPostLifecycle(ctx context.Context, req domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error) {
|
||||
if req.Now == 0 {
|
||||
req.Now = nowUnix()
|
||||
}
|
||||
if req.Limit <= 0 || req.Limit > 100 {
|
||||
req.Limit = 100
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT monoforum_id,suggestion_message_id
|
||||
FROM suggested_post_approvals a
|
||||
WHERE (a.state='scheduled' AND a.schedule_date <= $1)
|
||||
OR (a.state='scheduled' AND EXISTS (
|
||||
SELECT 1 FROM channel_messages sm
|
||||
WHERE sm.channel_id=a.monoforum_id AND sm.id=a.suggestion_message_id AND sm.deleted))
|
||||
OR (a.state='published' AND (a.settlement_due <= $1 OR EXISTS (
|
||||
SELECT 1 FROM channel_messages m
|
||||
WHERE m.channel_id=a.parent_channel_id AND m.id=a.published_message_id AND m.deleted)))
|
||||
ORDER BY CASE WHEN a.state='scheduled' THEN a.schedule_date ELSE a.settlement_due END,
|
||||
a.monoforum_id,a.suggestion_message_id
|
||||
LIMIT $2`, req.Now, req.Limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list due suggested posts: %w", err)
|
||||
}
|
||||
type key struct {
|
||||
mono int64
|
||||
message int
|
||||
}
|
||||
keys := make([]key, 0, req.Limit)
|
||||
for rows.Next() {
|
||||
var k key
|
||||
if err := rows.Scan(&k.mono, &k.message); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
out := make([]domain.ToggleSuggestedPostApprovalResult, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
result, changed, err := s.processSuggestedPostLifecycleOne(ctx, k.mono, k.message, req.Now)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if changed {
|
||||
out = append(out, result)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) processSuggestedPostLifecycleOne(ctx context.Context, monoID int64, messageID, now int) (domain.ToggleSuggestedPostApprovalResult, bool, error) {
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, fmt.Errorf("suggested post lifecycle: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
row, found, err := loadSuggestedPostApprovalTx(ctx, tx, monoID, messageID, true)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, fmt.Errorf("suggested post lifecycle invariant: approval row disappeared for monoforum %d message %d", monoID, messageID)
|
||||
}
|
||||
if row.state != domain.SuggestedPostStateScheduled && row.state != domain.SuggestedPostStatePublished {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
committed = true
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, nil
|
||||
}
|
||||
mono, err := getChannelByID(ctx, tx, row.monoforumID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
parent, err := getChannelByID(ctx, tx, row.parentID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
original, err := s.getChannelMessage(ctx, tx, row.monoforumID, row.messageID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
recipients, err := monoforumManagerRecipientsTx(ctx, tx, parent.ID, row.payerID)
|
||||
if err != nil {
|
||||
return domain.ToggleSuggestedPostApprovalResult{}, false, err
|
||||
}
|
||||
result := domain.ToggleSuggestedPostApprovalResult{Monoforum: mono, Parent: parent, SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: row.payerID}, State: row.state, Recipients: recipients}
|
||||
changed := false
|
||||
if row.state == domain.SuggestedPostStateScheduled && original.Deleted {
|
||||
if row.price != nil {
|
||||
stars, ton, err := refundSuggestedPostPaymentTx(ctx, tx, row.payerID, row.parentID, row.price, now)
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
|
||||
service, event, err := s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, row.actorID, true, row.savedPeer(), row.messageID, now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
result.ServiceMessage, result.ServiceEvent, row.finalServiceID = service, event, service.ID
|
||||
}
|
||||
row.state, result.State, changed = domain.SuggestedPostStateRefunded, domain.SuggestedPostStateRefunded, true
|
||||
}
|
||||
if row.state == domain.SuggestedPostStateScheduled && row.scheduleDate <= now {
|
||||
published, err := s.publishSuggestedPostTx(ctx, tx, parent, original, row.actorID, now)
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
result.Published = &published
|
||||
row.publishedMessageID = published.Message.ID
|
||||
if row.price == nil {
|
||||
row.state = domain.SuggestedPostStateCompleted
|
||||
} else {
|
||||
row.state = domain.SuggestedPostStatePublished
|
||||
row.settlementDue = now + suggestedPostSettlementAge
|
||||
}
|
||||
result.State = row.state
|
||||
changed = true
|
||||
}
|
||||
if row.state == domain.SuggestedPostStatePublished {
|
||||
var deleted bool
|
||||
var deleteDate int
|
||||
if err := tx.QueryRow(ctx, `SELECT deleted,delete_date FROM channel_messages WHERE channel_id=$1 AND id=$2 FOR SHARE`, row.parentID, row.publishedMessageID).Scan(&deleted, &deleteDate); err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
if deleted && (deleteDate == 0 || deleteDate < row.settlementDue) {
|
||||
stars, ton, err := refundSuggestedPostPaymentTx(ctx, tx, row.payerID, row.parentID, row.price, now)
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
result.PayerStarsBalance, result.PayerTONBalance = stars, ton
|
||||
service, event, err := s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, row.actorID, true, row.savedPeer(), row.messageID, now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostRefund})
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
result.ServiceMessage, result.ServiceEvent = service, event
|
||||
row.state, row.finalServiceID, result.State = domain.SuggestedPostStateRefunded, service.ID, domain.SuggestedPostStateRefunded
|
||||
changed = true
|
||||
} else if row.settlementDue <= now {
|
||||
if err := settleSuggestedPostPaymentTx(ctx, tx, row.actorID, row.payerID, row.parentID, row.price, now); err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
service, event, err := s.insertSuggestedPostServiceTx(ctx, tx, mono, parent, row.actorID, true, row.savedPeer(), row.messageID, now, domain.ChannelMessageAction{Type: domain.ChannelActionSuggestedPostSuccess, SuggestedPostPrice: cloneSuggestedPricePG(row.price)})
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
result.ServiceMessage, result.ServiceEvent = service, event
|
||||
row.state, row.finalServiceID, result.State = domain.SuggestedPostStateCompleted, service.ID, domain.SuggestedPostStateCompleted
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
committed = true
|
||||
return result, false, nil
|
||||
}
|
||||
if err := upsertSuggestedPostApprovalTx(ctx, tx, row, now); err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
committed = true
|
||||
result.Monoforum, err = getChannelByID(ctx, s.db, row.monoforumID)
|
||||
if err != nil {
|
||||
return result, true, fmt.Errorf("reload lifecycle monoforum after commit: %w", err)
|
||||
}
|
||||
result.Parent, err = getChannelByID(ctx, s.db, row.parentID)
|
||||
if err != nil {
|
||||
return result, true, fmt.Errorf("reload lifecycle parent after commit: %w", err)
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func (r persistedSuggestedPostApproval) savedPeer() domain.Peer {
|
||||
return domain.Peer{Type: domain.PeerTypeUser, ID: r.payerID}
|
||||
}
|
||||
|
||||
func refundSuggestedPostPaymentTx(ctx context.Context, tx pgx.Tx, payerID, parentID int64, price *domain.SuggestedPostPrice, date int) (*domain.StarsBalance, *int64, error) {
|
||||
if price == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
switch price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
balance := domain.StarsBalance{UserID: payerID, Granted: true}
|
||||
if err := tx.QueryRow(ctx, `INSERT INTO stars_balances(user_id,balance,granted) VALUES($1,$2,true) ON CONFLICT(user_id) DO UPDATE SET balance=stars_balances.balance+EXCLUDED.balance,updated_at=now() RETURNING balance,granted`, payerID, price.Amount).Scan(&balance.Balance, &balance.Granted); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := insertStarsTxn(ctx, tx, payerID, price.Amount, domain.StarsReasonSuggestedPost, domain.Peer{Type: domain.PeerTypeChannel, ID: parentID}, date, "Suggested post refund", ""); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &balance, nil, nil
|
||||
case domain.SuggestedPostPriceTON:
|
||||
var balance int64
|
||||
if err := tx.QueryRow(ctx, `INSERT INTO ton_balances(user_id,balance_nanoton,granted) VALUES($1,$2,true) ON CONFLICT(user_id) DO UPDATE SET balance_nanoton=ton_balances.balance_nanoton+EXCLUDED.balance_nanoton,updated_at=now() RETURNING balance_nanoton`, payerID, price.Amount).Scan(&balance); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions(user_id,amount_nanoton,reason,peer_type,peer_id,date) VALUES($1,$2,$3,'channel',$4,$5)`, payerID, price.Amount, string(domain.StarsReasonSuggestedPost), parentID, date); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, &balance, nil
|
||||
default:
|
||||
return nil, nil, domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func settleSuggestedPostPaymentTx(ctx context.Context, tx pgx.Tx, actorID, payerID, parentID int64, price *domain.SuggestedPostPrice, date int) error {
|
||||
if price == nil {
|
||||
return nil
|
||||
}
|
||||
credit := price.Amount * paidMessageChannelCommissionPermille / 1000
|
||||
if credit <= 0 {
|
||||
return nil
|
||||
}
|
||||
switch price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO channel_stars_balances(channel_id,balance) VALUES($1,$2) ON CONFLICT(channel_id) DO UPDATE SET balance=channel_stars_balances.balance+EXCLUDED.balance,updated_at=now()`, parentID, credit); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(ctx, `INSERT INTO channel_stars_transactions(channel_id,actor_user_id,amount,reason,peer_type,peer_id,date) VALUES($1,$2,$3,$4,'user',$5,$6)`, parentID, actorID, credit, string(domain.StarsReasonSuggestedPost), payerID, date)
|
||||
return err
|
||||
case domain.SuggestedPostPriceTON:
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO channel_ton_balances(channel_id,balance_nanoton) VALUES($1,$2) ON CONFLICT(channel_id) DO UPDATE SET balance_nanoton=channel_ton_balances.balance_nanoton+EXCLUDED.balance_nanoton,updated_at=now()`, parentID, credit); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(ctx, `INSERT INTO channel_ton_transactions(channel_id,actor_user_id,amount_nanoton,reason,peer_type,peer_id,date) VALUES($1,$2,$3,$4,'user',$5,$6)`, parentID, actorID, credit, string(domain.StarsReasonSuggestedPost), payerID, date)
|
||||
return err
|
||||
default:
|
||||
return domain.ErrSuggestedPostInvalid
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// TestSuggestedPostLifecyclePostgres verifies that message state, channel pts,
|
||||
// escrow and refund are committed through the real PostgreSQL transaction.
|
||||
// It is gated by TELESRV_TEST_POSTGRES_DSN and testPool migrates through 0134.
|
||||
func TestSuggestedPostLifecyclePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 201, Phone: "+1888" + suffix + "01", FirstName: "SuggestOwner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subscriber, err := users.Create(ctx, domain.User{AccessHash: 202, Phone: "+1888" + suffix + "02", FirstName: "SuggestSubscriber"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channels := NewChannelStore(pool)
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Suggested " + suffix, Broadcast: true, Date: 1_700_000_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
monoID := enabled.Channel.LinkedMonoforumID
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM suggested_post_approvals WHERE monoforum_id=$1`, monoID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM channel_stars_balances WHERE channel_id=$1`, created.Channel.ID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id=ANY($1::bigint[])`, []int64{monoID, created.Channel.ID})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1::bigint[])`, []int64{owner.ID, subscriber.ID})
|
||||
})
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO stars_balances(user_id,balance,granted) VALUES($1,100,true) ON CONFLICT(user_id) DO UPDATE SET balance=100,granted=true`, subscriber.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved := domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID}
|
||||
suggestion, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 71, Message: "postgres suggestion", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_000_100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approved, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: owner.ID, MonoforumID: monoID, MessageID: suggestion.Message.ID, Date: 1_700_000_200})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if approved.State != domain.SuggestedPostStatePublished || approved.Published == nil || approved.PayerStarsBalance == nil || approved.PayerStarsBalance.Balance != 90 {
|
||||
t.Fatalf("approved=%+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)
|
||||
}
|
||||
history, err := channels.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: saved, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var persistedApprovalDate int
|
||||
for _, message := range history.Messages {
|
||||
if message.ID == approved.ServiceMessage.ID && message.Action != nil {
|
||||
persistedApprovalDate = message.Action.SuggestedPostScheduleDate
|
||||
break
|
||||
}
|
||||
}
|
||||
if persistedApprovalDate != 1_700_000_200 {
|
||||
t.Fatalf("persisted approval history date=%d, want commit date", persistedApprovalDate)
|
||||
}
|
||||
replay, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: owner.ID, MonoforumID: monoID, MessageID: suggestion.Message.ID, Date: 1_700_000_201})
|
||||
if err != nil || !replay.Duplicate || replay.OriginalEvent.Type != domain.ChannelUpdateEditMessage || replay.ServiceEvent.Type != domain.ChannelUpdateNewMessage || replay.Published == nil {
|
||||
t.Fatalf("approval replay=%+v err=%v", replay, err)
|
||||
}
|
||||
var state string
|
||||
var scheduleDate int
|
||||
var debit, channelBalance int64
|
||||
if err := pool.QueryRow(ctx, `SELECT state,schedule_date FROM suggested_post_approvals WHERE monoforum_id=$1 AND suggestion_message_id=$2`, monoID, suggestion.Message.ID).Scan(&state, &scheduleDate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, subscriber.ID).Scan(&debit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = pool.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, created.Channel.ID).Scan(&channelBalance)
|
||||
if state != string(domain.SuggestedPostStatePublished) || scheduleDate != 1_700_000_200 || debit != 90 || channelBalance != 0 {
|
||||
t.Fatalf("state/schedule/debit/channel=%s/%d/%d/%d", state, scheduleDate, debit, channelBalance)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE channel_messages SET deleted=true WHERE channel_id=$1 AND id=$2`, created.Channel.ID, approved.Published.Message.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := channels.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: 1_700_000_300, Limit: 10})
|
||||
if err != nil || len(resolved) != 1 || resolved[0].State != domain.SuggestedPostStateRefunded || resolved[0].ServiceMessage.Action == nil || resolved[0].ServiceMessage.Action.Type != domain.ChannelActionSuggestedPostRefund {
|
||||
t.Fatalf("refund=%+v err=%v", resolved, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, subscriber.ID).Scan(&debit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var txnNet int64
|
||||
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount),0) FROM stars_transactions WHERE user_id=$1 AND reason=$2`, subscriber.ID, string(domain.StarsReasonSuggestedPost)).Scan(&txnNet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if debit != 100 || txnNet != 0 {
|
||||
t.Fatalf("refund balance/net=%d/%d, want 100/0", debit, txnNet)
|
||||
}
|
||||
|
||||
late, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 72, Message: "late deletion", SuggestedPost: &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}, Date: 1_700_000_400})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approvedAt := 1_700_000_500
|
||||
lateApproved, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{UserID: owner.ID, MonoforumID: monoID, MessageID: late.Message.ID, Date: approvedAt})
|
||||
if err != nil || lateApproved.Published == nil {
|
||||
t.Fatalf("late approval=%+v err=%v", lateApproved, err)
|
||||
}
|
||||
due := approvedAt + suggestedPostSettlementAge
|
||||
if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{UserID: owner.ID, ChannelID: created.Channel.ID, IDs: []int{lateApproved.Published.Message.ID}, Date: due + 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err = channels.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("late settlement=%+v err=%v", resolved, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, subscriber.ID).Scan(&debit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM channel_stars_balances WHERE channel_id=$1),0)`, created.Channel.ID).Scan(&channelBalance); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if debit != 90 || channelBalance != 8 {
|
||||
t.Fatalf("late settlement balance/channel=%d/%d, want 90/8", debit, channelBalance)
|
||||
}
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.Cha
|
|||
args = append(args, member.AvailableMinID)
|
||||
where += fmt.Sprintf(" AND id > $%d", len(args))
|
||||
}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
if channel.Monoforum && !member.CanManageDirectMessages() {
|
||||
args = append(args, req.UserID)
|
||||
where += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(args))
|
||||
}
|
||||
|
|
@ -147,7 +147,7 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
}
|
||||
rows.Close()
|
||||
var visibleMonoforumMessageIDs map[int]struct{}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
if channel.Monoforum && !member.CanManageDirectMessages() {
|
||||
messageIDs := make([]int, 0)
|
||||
for _, row := range eventRows {
|
||||
messageIDs = append(messageIDs, row.event.MessageIDs...)
|
||||
|
|
@ -172,7 +172,7 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
continue
|
||||
}
|
||||
event = visibleEvent
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
if channel.Monoforum && !member.CanManageDirectMessages() {
|
||||
event, ok = filterMonoforumEventForUser(event, req.UserID, visibleMonoforumMessageIDs)
|
||||
if !ok {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("migrate star gift lifecycle schema: %v", err)
|
||||
}
|
||||
if status.Dirty || status.Empty || status.Version != 132 {
|
||||
t.Fatalf("migration status = %+v, want clean version 132", status)
|
||||
if status.Dirty || status.Empty || status.Version != 134 {
|
||||
t.Fatalf("migration status = %+v, want clean version 134", status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue