diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 35556cab..a78c8ca0 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -933,6 +933,7 @@ func run(logger *zap.Logger) error { ).Run(ctx) go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx) go rpc.NewScheduledDispatcher(router, logger.Named("rpc").Named("scheduled")).Run(ctx) + go rpc.NewSuggestedPostDispatcher(router, logger.Named("rpc").Named("suggested-post")).Run(ctx) go rpc.NewExpiryDispatcher(router, logger.Named("rpc").Named("expiry")).Run(ctx) go rpc.NewPhoneExpiryDispatcher(router, logger.Named("rpc").Named("phone-expiry"), cfg.CallExpiryInterval).Run(ctx) go rpc.NewGroupCallSweepDispatcher(router, logger.Named("rpc").Named("groupcall-sweep"), cfg.GroupCallSweepInterval, cfg.GroupCallCheckTTL).Run(ctx) diff --git a/deploy/migrations/0133_suggested_post_lifecycle.down.sql b/deploy/migrations/0133_suggested_post_lifecycle.down.sql new file mode 100644 index 00000000..5ef581c2 --- /dev/null +++ b/deploy/migrations/0133_suggested_post_lifecycle.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.suggested_post_approvals; diff --git a/deploy/migrations/0133_suggested_post_lifecycle.up.sql b/deploy/migrations/0133_suggested_post_lifecycle.up.sql new file mode 100644 index 00000000..1ae81ae9 --- /dev/null +++ b/deploy/migrations/0133_suggested_post_lifecycle.up.sql @@ -0,0 +1,39 @@ +-- Durable suggested-post approval/payment/publication state. The row is the +-- idempotency key for a monoforum suggestion; message/update rows remain the +-- client-visible source of truth and are written in the same transaction. +CREATE TABLE public.suggested_post_approvals ( + monoforum_id bigint NOT NULL, + suggestion_message_id integer NOT NULL, + parent_channel_id bigint NOT NULL, + actor_user_id bigint NOT NULL, + payer_user_id bigint NOT NULL, + state text NOT NULL, + price_kind text NOT NULL DEFAULT '', + price_amount bigint NOT NULL DEFAULT 0, + price_nanos integer NOT NULL DEFAULT 0, + schedule_date integer NOT NULL DEFAULT 0, + approval_service_message_id integer NOT NULL DEFAULT 0, + published_message_id integer NOT NULL DEFAULT 0, + settlement_due integer NOT NULL DEFAULT 0, + final_service_message_id integer NOT NULL DEFAULT 0, + created_at integer NOT NULL, + updated_at integer NOT NULL, + PRIMARY KEY (monoforum_id, suggestion_message_id), + CONSTRAINT suggested_post_approvals_shape_check CHECK ( + monoforum_id>0 AND suggestion_message_id>0 AND parent_channel_id>0 AND + actor_user_id>0 AND payer_user_id>0 AND created_at>0 AND updated_at>=created_at AND + state IN ('balance_low','rejected','scheduled','published','completed','refunded') AND + price_kind IN ('','stars','ton') AND price_amount>=0 AND price_nanos BETWEEN 0 AND 999999999 AND + ((price_kind='' AND price_amount=0 AND price_nanos=0) OR + (price_kind='stars' AND price_amount>0) OR + (price_kind='ton' AND price_amount>0 AND price_nanos=0)) AND + schedule_date>=0 AND approval_service_message_id>=0 AND published_message_id>=0 AND + settlement_due>=0 AND final_service_message_id>=0) +); + +CREATE INDEX suggested_post_approvals_schedule_idx + ON public.suggested_post_approvals(schedule_date,monoforum_id,suggestion_message_id) + WHERE state='scheduled'; +CREATE INDEX suggested_post_approvals_settlement_idx + ON public.suggested_post_approvals(settlement_due,monoforum_id,suggestion_message_id) + WHERE state='published'; diff --git a/deploy/migrations/0134_suggested_post_effective_publish_date.down.sql b/deploy/migrations/0134_suggested_post_effective_publish_date.down.sql new file mode 100644 index 00000000..3d4020cb --- /dev/null +++ b/deploy/migrations/0134_suggested_post_effective_publish_date.down.sql @@ -0,0 +1,17 @@ +-- The data backfill is intentionally retained on rollback. Restore only the +-- pre-0134 shape constraint, which allowed zero schedule_date in every state. +ALTER TABLE suggested_post_approvals + DROP CONSTRAINT suggested_post_approvals_shape_check; + +ALTER TABLE suggested_post_approvals + ADD CONSTRAINT suggested_post_approvals_shape_check CHECK ( + monoforum_id>0 AND suggestion_message_id>0 AND parent_channel_id>0 AND + actor_user_id>0 AND payer_user_id>0 AND created_at>0 AND updated_at>=created_at AND + state IN ('balance_low','rejected','scheduled','published','completed','refunded') AND + price_kind IN ('','stars','ton') AND price_amount>=0 AND price_nanos BETWEEN 0 AND 999999999 AND + ((price_kind='' AND price_amount=0 AND price_nanos=0) OR + (price_kind='stars' AND price_amount>0) OR + (price_kind='ton' AND price_amount>0 AND price_nanos=0)) AND + schedule_date>=0 AND approval_service_message_id>=0 AND published_message_id>=0 AND + settlement_due>=0 AND final_service_message_id>=0 + ); diff --git a/deploy/migrations/0134_suggested_post_effective_publish_date.up.sql b/deploy/migrations/0134_suggested_post_effective_publish_date.up.sql new file mode 100644 index 00000000..c76172fa --- /dev/null +++ b/deploy/migrations/0134_suggested_post_effective_publish_date.up.sql @@ -0,0 +1,93 @@ +-- TDesktop omits schedule_date for "Publish Now", while the approval action +-- renderer always formats an absolute publication date. Backfill rows written +-- by the initial lifecycle implementation and keep current/history/difference +-- projections on the same effective timestamp. +UPDATE channel_messages m +SET suggested_post = jsonb_set( + m.suggested_post, + '{ScheduleDate}', + to_jsonb(a.created_at), + true + ) +FROM suggested_post_approvals a +WHERE a.schedule_date = 0 + AND a.state IN ('scheduled', 'published', 'completed', 'refunded') + AND m.channel_id = a.monoforum_id + AND m.id = a.suggestion_message_id + AND COALESCE((m.suggested_post->>'Accepted')::boolean, false) + AND COALESCE((m.suggested_post->>'ScheduleDate')::integer, 0) = 0; + +UPDATE channel_messages m +SET action = jsonb_set( + m.action, + '{SuggestedPostScheduleDate}', + to_jsonb(a.created_at), + true + ) +FROM suggested_post_approvals a +WHERE a.schedule_date = 0 + AND a.state IN ('scheduled', 'published', 'completed', 'refunded') + AND m.channel_id = a.monoforum_id + AND m.id = a.approval_service_message_id + AND m.action->>'Type' = 'suggested_post_approval' + AND NOT COALESCE((m.action->>'SuggestedPostRejected')::boolean, false) + AND NOT COALESCE((m.action->>'SuggestedPostBalanceTooLow')::boolean, false) + AND COALESCE((m.action->>'SuggestedPostScheduleDate')::integer, 0) = 0; + +UPDATE channel_update_events e +SET payload = jsonb_set( + e.payload, + '{message,SuggestedPost,ScheduleDate}', + to_jsonb(a.created_at), + true + ) +FROM suggested_post_approvals a +WHERE a.schedule_date = 0 + AND a.state IN ('scheduled', 'published', 'completed', 'refunded') + AND e.channel_id = a.monoforum_id + AND e.message_id = a.suggestion_message_id + AND e.event_type = 'edit_channel_message' + AND COALESCE((e.payload #>> '{message,SuggestedPost,Accepted}')::boolean, false) + AND COALESCE((e.payload #>> '{message,SuggestedPost,ScheduleDate}')::integer, 0) = 0; + +UPDATE channel_update_events e +SET payload = jsonb_set( + e.payload, + '{message,Action,SuggestedPostScheduleDate}', + to_jsonb(a.created_at), + true + ) +FROM suggested_post_approvals a +WHERE a.schedule_date = 0 + AND a.state IN ('scheduled', 'published', 'completed', 'refunded') + AND e.channel_id = a.monoforum_id + AND e.message_id = a.approval_service_message_id + AND e.event_type = 'new_channel_message' + AND e.payload #>> '{message,Action,Type}' = 'suggested_post_approval' + AND NOT COALESCE((e.payload #>> '{message,Action,SuggestedPostRejected}')::boolean, false) + AND NOT COALESCE((e.payload #>> '{message,Action,SuggestedPostBalanceTooLow}')::boolean, false) + AND COALESCE((e.payload #>> '{message,Action,SuggestedPostScheduleDate}')::integer, 0) = 0; + +UPDATE suggested_post_approvals +SET schedule_date = created_at, + updated_at = GREATEST(updated_at, created_at) +WHERE schedule_date = 0 + AND state IN ('scheduled', 'published', 'completed', 'refunded'); + +ALTER TABLE suggested_post_approvals + DROP CONSTRAINT suggested_post_approvals_shape_check; + +ALTER TABLE suggested_post_approvals + ADD CONSTRAINT suggested_post_approvals_shape_check CHECK ( + monoforum_id>0 AND suggestion_message_id>0 AND parent_channel_id>0 AND + actor_user_id>0 AND payer_user_id>0 AND created_at>0 AND updated_at>=created_at AND + state IN ('balance_low','rejected','scheduled','published','completed','refunded') AND + price_kind IN ('','stars','ton') AND price_amount>=0 AND price_nanos BETWEEN 0 AND 999999999 AND + ((price_kind='' AND price_amount=0 AND price_nanos=0) OR + (price_kind='stars' AND price_amount>0) OR + (price_kind='ton' AND price_amount>0 AND price_nanos=0)) AND + schedule_date>=0 AND + (state IN ('balance_low','rejected') OR schedule_date>0) AND + approval_service_message_id>=0 AND published_message_id>=0 AND + settlement_due>=0 AND final_service_message_id>=0 + ); diff --git a/internal/app/channels/service_suggested_post.go b/internal/app/channels/service_suggested_post.go new file mode 100644 index 00000000..c6bed8cc --- /dev/null +++ b/internal/app/channels/service_suggested_post.go @@ -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) +} diff --git a/internal/domain/channel.go b/internal/domain/channel.go index 027956a6..941b356b 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -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. diff --git a/internal/domain/channel_errors.go b/internal/domain/channel_errors.go index 92c2f2d0..7a747af5 100644 --- a/internal/domain/channel_errors.go +++ b/internal/domain/channel_errors.go @@ -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. diff --git a/internal/domain/stars.go b/internal/domain/stars.go index dc3f1b60..0d2f45f3 100644 --- a/internal/domain/stars.go +++ b/internal/domain/stars.go @@ -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。 diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go index 44ae66fe..5d116f3d 100644 --- a/internal/rpc/convert_channels_core.go +++ b/internal/rpc/convert_channels_core.go @@ -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 } diff --git a/internal/rpc/messages_monoforum.go b/internal/rpc/messages_monoforum.go index fabfb9ce..8943da19 100644 --- a/internal/rpc/messages_monoforum.go +++ b/internal/rpc/messages_monoforum.go @@ -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() diff --git a/internal/rpc/messages_monoforum_rpc_test.go b/internal/rpc/messages_monoforum_rpc_test.go index fd9057bc..3480ffad 100644 --- a/internal/rpc/messages_monoforum_rpc_test.go +++ b/internal/rpc/messages_monoforum_rpc_test.go @@ -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") diff --git a/internal/rpc/messages_register.go b/internal/rpc/messages_register.go index 809f1c5a..8e025d05 100644 --- a/internal/rpc/messages_register.go +++ b/internal/rpc/messages_register.go @@ -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) }) diff --git a/internal/rpc/messages_suggested_post.go b/internal/rpc/messages_suggested_post.go index 8cb59754..dc009224 100644 --- a/internal/rpc/messages_suggested_post.go +++ b/internal/rpc/messages_suggested_post.go @@ -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} diff --git a/internal/rpc/messages_suggested_post_rpc_test.go b/internal/rpc/messages_suggested_post_rpc_test.go new file mode 100644 index 00000000..2cfb9cf0 --- /dev/null +++ b/internal/rpc/messages_suggested_post_rpc_test.go @@ -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) + } + } +} diff --git a/internal/rpc/messages_suggested_post_updates.go b/internal/rpc/messages_suggested_post_updates.go new file mode 100644 index 00000000..cdf84a15 --- /dev/null +++ b/internal/rpc/messages_suggested_post_updates.go @@ -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) + } +} diff --git a/internal/rpc/suggested_post_dispatcher.go b/internal/rpc/suggested_post_dispatcher.go new file mode 100644 index 00000000..0ef6ae07 --- /dev/null +++ b/internal/rpc/suggested_post_dispatcher.go @@ -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 +} diff --git a/internal/store/memory/channel_dialogs.go b/internal/store/memory/channel_dialogs.go index a1de0199..dfbd19cc 100644 --- a/internal/store/memory/channel_dialogs.go +++ b/internal/store/memory/channel_dialogs.go @@ -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) diff --git a/internal/store/memory/channel_helpers.go b/internal/store/memory/channel_helpers.go index bda20e6c..0d79ef0b 100644 --- a/internal/store/memory/channel_helpers.go +++ b/internal/store/memory/channel_helpers.go @@ -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] diff --git a/internal/store/memory/channel_message_helpers.go b/internal/store/memory/channel_message_helpers.go index d42ea089..897d063a 100644 --- a/internal/store/memory/channel_message_helpers.go +++ b/internal/store/memory/channel_message_helpers.go @@ -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 diff --git a/internal/store/memory/channel_message_history.go b/internal/store/memory/channel_message_history.go index 0a881f6d..d62bfd7c 100644 --- a/internal/store/memory/channel_message_history.go +++ b/internal/store/memory/channel_message_history.go @@ -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 diff --git a/internal/store/memory/channel_monoforum.go b/internal/store/memory/channel_monoforum.go index 8634f7dc..1df62b24 100644 --- a/internal/store/memory/channel_monoforum.go +++ b/internal/store/memory/channel_monoforum.go @@ -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 } diff --git a/internal/store/memory/channel_store.go b/internal/store/memory/channel_store.go index abcc0033..77788026 100644 --- a/internal/store/memory/channel_store.go +++ b/internal/store/memory/channel_store.go @@ -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), } } diff --git a/internal/store/memory/channel_suggested_post.go b/internal/store/memory/channel_suggested_post.go new file mode 100644 index 00000000..37dec1f5 --- /dev/null +++ b/internal/store/memory/channel_suggested_post.go @@ -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 +} diff --git a/internal/store/memory/channel_suggested_post_test.go b/internal/store/memory/channel_suggested_post_test.go new file mode 100644 index 00000000..3e4d3c80 --- /dev/null +++ b/internal/store/memory/channel_suggested_post_test.go @@ -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]) + } +} diff --git a/internal/store/memory/channel_updates.go b/internal/store/memory/channel_updates.go index 8f626a96..033daca0 100644 --- a/internal/store/memory/channel_updates.go +++ b/internal/store/memory/channel_updates.go @@ -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 diff --git a/internal/store/postgres/channel_dialogs.go b/internal/store/postgres/channel_dialogs.go index d4dcda87..50a5baa7 100644 --- a/internal/store/postgres/channel_dialogs.go +++ b/internal/store/postgres/channel_dialogs.go @@ -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, diff --git a/internal/store/postgres/channel_helpers.go b/internal/store/postgres/channel_helpers.go index c3650cab..87c03e93 100644 --- a/internal/store/postgres/channel_helpers.go +++ b/internal/store/postgres/channel_helpers.go @@ -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 diff --git a/internal/store/postgres/channel_member_helpers.go b/internal/store/postgres/channel_member_helpers.go index ff26a5bb..9d4f9360 100644 --- a/internal/store/postgres/channel_member_helpers.go +++ b/internal/store/postgres/channel_member_helpers.go @@ -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 diff --git a/internal/store/postgres/channel_message_history.go b/internal/store/postgres/channel_message_history.go index 8b5a9d94..61971770 100644 --- a/internal/store/postgres/channel_message_history.go +++ b/internal/store/postgres/channel_message_history.go @@ -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) diff --git a/internal/store/postgres/channel_monoforum.go b/internal/store/postgres/channel_monoforum.go index ff728a92..7baf770c 100644 --- a/internal/store/postgres/channel_monoforum.go +++ b/internal/store/postgres/channel_monoforum.go @@ -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 } diff --git a/internal/store/postgres/channel_suggested_post.go b/internal/store/postgres/channel_suggested_post.go new file mode 100644 index 00000000..9cde2a95 --- /dev/null +++ b/internal/store/postgres/channel_suggested_post.go @@ -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 + } +} diff --git a/internal/store/postgres/channel_suggested_post_integration_test.go b/internal/store/postgres/channel_suggested_post_integration_test.go new file mode 100644 index 00000000..4db4f512 --- /dev/null +++ b/internal/store/postgres/channel_suggested_post_integration_test.go @@ -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) + } +} diff --git a/internal/store/postgres/channel_updates.go b/internal/store/postgres/channel_updates.go index bf03c14c..24bef8ae 100644 --- a/internal/store/postgres/channel_updates.go +++ b/internal/store/postgres/channel_updates.go @@ -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 diff --git a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go index eb0a5382..5000638f 100644 --- a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go @@ -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) } }