fix: sync monoforum suggested post flow

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

View file

@ -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
}

View file

@ -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()

View file

@ -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")

View file

@ -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)
})

View file

@ -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}

View file

@ -0,0 +1,158 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestMessagesToggleSuggestedPostApprovalRegisteredAndProjectsLifecycle(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
owner, err := users.Create(ctx, domain.User{AccessHash: 101, Phone: "15551110001", FirstName: "Owner"})
if err != nil {
t.Fatal(err)
}
subscriber, err := users.Create(ctx, domain.User{AccessHash: 102, Phone: "15551110002", FirstName: "Subscriber"})
if err != nil {
t.Fatal(err)
}
channelsStore := memory.NewChannelStore()
channels := appchannels.NewService(channelsStore)
created, err := channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "Suggested", Broadcast: true, Date: 1_700_000_000})
if err != nil {
t.Fatal(err)
}
enabled, err := channelsStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
if err != nil {
t.Fatal(err)
}
mono, err := channelsStore.GetChannelByID(ctx, enabled.Channel.LinkedMonoforumID)
if err != nil {
t.Fatal(err)
}
saved := domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID}
suggestion, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 91, Message: "RPC suggestion", SuggestedPost: &domain.SuggestedPost{}, Date: 1_700_000_100})
if err != nil {
t.Fatal(err)
}
router := New(Config{}, Deps{Users: appusers.NewService(users), Channels: channels}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesToggleSuggestedPostApprovalRequest{Peer: &tg.InputPeerChannel{ChannelID: mono.ID, AccessHash: mono.AccessHash}, MsgID: suggestion.Message.ID}
var raw bin.Buffer
if err := req.Encode(&raw); err != nil {
t.Fatal(err)
}
response, err := router.Dispatch(WithLayer(WithUserID(ctx, owner.ID), 228), [8]byte{}, 0, &raw)
if err != nil {
t.Fatalf("dispatch toggleSuggestedPostApproval: %v", err)
}
updates, ok := response.(*tg.Updates)
if !ok {
t.Fatalf("response=%T, want *tg.Updates", response)
}
var edited, approval, published bool
for _, update := range updates.Updates {
switch item := update.(type) {
case *tg.UpdateEditChannelMessage:
message, ok := item.Message.(*tg.Message)
if ok && message.ID == suggestion.Message.ID {
post, present := message.GetSuggestedPost()
edited = present && post.GetAccepted()
}
case *tg.UpdateNewChannelMessage:
switch message := item.Message.(type) {
case *tg.MessageService:
action, ok := message.Action.(*tg.MessageActionSuggestedPostApproval)
if ok {
scheduleDate, hasScheduleDate := action.GetScheduleDate()
approval = !action.Rejected && !action.BalanceTooLow && hasScheduleDate && scheduleDate > 0
}
if savedPeer, present := message.GetSavedPeerID(); !present {
t.Fatalf("approval service missing saved_peer_id")
} else if peer, ok := savedPeer.(*tg.PeerUser); !ok || peer.UserID != subscriber.ID {
t.Fatalf("approval saved_peer=%#v", savedPeer)
}
case *tg.Message:
published = message.PeerID.(*tg.PeerChannel).ChannelID == created.Channel.ID && message.Post && message.Message == "RPC suggestion"
}
}
}
if !edited || !approval || !published {
t.Fatalf("updates missing edit/approval/publish: %#v", updates.Updates)
}
var retryRaw bin.Buffer
if err := req.Encode(&retryRaw); err != nil {
t.Fatal(err)
}
retry, err := router.Dispatch(WithLayer(WithUserID(ctx, owner.ID), 227), [8]byte{}, 0, &retryRaw)
if err != nil {
t.Fatalf("layer 227 retry: %v", err)
}
got, ok := retry.(*tg.Updates)
if !ok {
t.Fatalf("layer 227 response=%T", retry)
}
// Duplicate replay returns the persisted approval + published update to
// the caller but is never fanned out again.
if len(got.Updates) != 3 {
t.Fatalf("layer 227 duplicate updates=%d, want 3", len(got.Updates))
}
}
func TestSuggestedPostTLProjectionSeparatesSuggestionAndPublishedPaymentFlags(t *testing.T) {
original := domain.ChannelMessage{ChannelID: 10, ID: 1, SenderUserID: 20, From: domain.Peer{Type: domain.PeerTypeUser, ID: 20}, SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 20}, Date: 100, Body: "proposal", SuggestedPost: &domain.SuggestedPost{Accepted: true, Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}}
proposal := tgChannelMessage(20, original).(*tg.Message)
if _, present := proposal.GetSuggestedPost(); !present || proposal.GetPaidSuggestedPostStars() {
t.Fatalf("proposal flags=%+v", proposal)
}
published := original
published.ChannelID, published.ID, published.Post, published.SavedPeer = 11, 2, true, domain.Peer{}
post := tgChannelMessage(20, published).(*tg.Message)
if !post.GetPaidSuggestedPostStars() {
t.Fatalf("published Stars post missing paid flag")
}
if _, present := post.GetSuggestedPost(); present {
t.Fatalf("published post leaked suggested_post")
}
published.SuggestedPost.Price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceTON, Amount: 10_000_000}
ton := tgChannelMessage(20, published).(*tg.Message)
if !ton.GetPaidSuggestedPostTon() || ton.GetPaidSuggestedPostStars() {
t.Fatalf("published TON flags=%+v", ton)
}
}
func TestSuggestedPostApprovalScheduleDateSurvivesExactProfiles(t *testing.T) {
action := tgChannelMessageAction(domain.ChannelMessageAction{
Type: domain.ChannelActionSuggestedPostApproval,
SuggestedPostScheduleDate: 1_700_000_200,
})
for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} {
wire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, action, wire); err != nil {
t.Fatalf("encode Layer %d approval action: %v", profile, err)
}
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Copy()}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d approval action: %v", profile, err)
}
decoded, ok := decodedObject.(*tg.MessageActionSuggestedPostApproval)
if !ok {
t.Fatalf("decode Layer %d approval action = %T", profile, decodedObject)
}
date, present := decoded.GetScheduleDate()
if !present || date != 1_700_000_200 {
t.Fatalf("Layer %d approval date=%d/%v, want 1700000200/true", profile, date, present)
}
}
}

View file

@ -0,0 +1,65 @@
package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func (r *Router) suggestedPostApprovalUpdates(ctx context.Context, viewerUserID int64, result domain.ToggleSuggestedPostApprovalResult) *tg.Updates {
updates := make([]tg.UpdateClass, 0, 4)
if result.OriginalEvent.Pts > 0 {
if update := tgChannelUpdate(viewerUserID, result.OriginalEvent); update != nil {
updates = append(updates, update)
}
}
if result.ServiceEvent.Pts > 0 {
if update := tgChannelUpdate(viewerUserID, result.ServiceEvent); update != nil {
updates = append(updates, update)
}
}
if result.Published != nil && result.Published.Event.Pts > 0 {
if update := tgChannelUpdate(viewerUserID, result.Published.Event); update != nil {
updates = append(updates, update)
}
}
if result.PayerStarsBalance != nil && result.PayerStarsBalance.UserID == viewerUserID {
updates = append(updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.PayerStarsBalance.Balance}})
}
chats := r.monoforumChats(ctx, viewerUserID, result.Monoforum)
if result.Parent.ID != 0 {
chats = appendUniqueTGChats(chats, tgChannelChatMin(viewerUserID, result.Parent))
}
messages := make([]domain.ChannelMessage, 0, 3)
if result.OriginalMessage.ID != 0 {
messages = append(messages, result.OriginalMessage)
}
if result.ServiceMessage.ID != 0 {
messages = append(messages, result.ServiceMessage)
}
if result.Published != nil {
messages = append(messages, result.Published.Message)
}
return &tg.Updates{
Updates: updates,
Chats: chats,
Users: r.monoforumSubscriberUsers(ctx, viewerUserID, []domain.MonoforumDialog{{SavedPeer: result.SavedPeer}}, messages),
Date: int(r.clock.Now().Unix()),
}
}
func (r *Router) enqueueSuggestedPostApprovalFanout(ctx context.Context, originUserID int64, result domain.ToggleSuggestedPostApprovalResult) {
monoOnly := result
monoOnly.Published = nil
nudge := max(result.OriginalEvent.Pts, result.ServiceEvent.Pts)
if nudge > 0 {
r.enqueueChannelFanout(ctx, channelFanoutExplicit, originUserID, result.Monoforum.ID, nudge, result.Recipients, func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
return r.suggestedPostApprovalUpdates(bgCtx, viewerUserID, monoOnly)
})
}
if result.Published != nil && result.Published.Event.Pts > 0 {
r.enqueueChannelMessageFanout(ctx, originUserID, *result.Published, nil)
}
}

View file

@ -0,0 +1,59 @@
package rpc
import (
"context"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
)
// SuggestedPostDispatcher publishes scheduled suggestions and resolves paid
// escrow after the minimum live age (or refunds it when the post is deleted).
// Store-side row locks make multiple server instances safe.
type SuggestedPostDispatcher struct {
router *Router
log *zap.Logger
interval time.Duration
batch int
}
func NewSuggestedPostDispatcher(router *Router, log *zap.Logger) *SuggestedPostDispatcher {
if log == nil {
log = zap.NewNop()
}
return &SuggestedPostDispatcher{router: router, log: log, interval: time.Second, batch: 50}
}
func (d *SuggestedPostDispatcher) Run(ctx context.Context) {
if d == nil || d.router == nil {
return
}
ticker := time.NewTicker(d.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
d.DispatchOnce(ctx)
}
}
}
func (d *SuggestedPostDispatcher) DispatchOnce(ctx context.Context) bool {
service, ok := d.router.deps.Channels.(suggestedPostApprovalService)
if !ok {
return false
}
results, err := service.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: int(d.router.clock.Now().Unix()), Limit: d.batch})
if err != nil {
d.log.Warn("process suggested post lifecycle", zap.Error(err))
return false
}
for _, result := range results {
d.router.enqueueSuggestedPostApprovalFanout(ctx, 0, result)
}
return len(results) > 0
}