fix: sync monoforum suggested post flow
This commit is contained in:
parent
511f25efc2
commit
401a8f148e
35 changed files with 2415 additions and 144 deletions
|
|
@ -113,6 +113,9 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
|
|||
if msg.Action == nil {
|
||||
msg.Action = &tg.MessageActionEmpty{}
|
||||
}
|
||||
if m.SavedPeer.ID != 0 {
|
||||
msg.SetSavedPeerID(tgPeer(m.SavedPeer))
|
||||
}
|
||||
if reply := tgMessageReplyHeader(domain.Message{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: m.ChannelID},
|
||||
ReplyTo: m.ReplyTo,
|
||||
|
|
@ -140,7 +143,18 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
|
|||
msg.SetSavedPeerID(tgPeer(m.SavedPeer))
|
||||
}
|
||||
if suggested, ok := tgSuggestedPost(m.SuggestedPost); ok {
|
||||
msg.SetSuggestedPost(suggested)
|
||||
if m.Post {
|
||||
if m.SuggestedPost != nil && m.SuggestedPost.Accepted && m.SuggestedPost.Price != nil {
|
||||
switch m.SuggestedPost.Price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
msg.SetPaidSuggestedPostStars(true)
|
||||
case domain.SuggestedPostPriceTON:
|
||||
msg.SetPaidSuggestedPostTon(true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
msg.SetSuggestedPost(suggested)
|
||||
}
|
||||
}
|
||||
if m.PaidMessageStars > 0 {
|
||||
msg.SetPaidMessageStars(m.PaidMessageStars)
|
||||
|
|
@ -303,6 +317,42 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
|
|||
out.SetCommunityID(action.CommunityID)
|
||||
}
|
||||
return out
|
||||
case domain.ChannelActionSuggestedPostApproval:
|
||||
out := &tg.MessageActionSuggestedPostApproval{
|
||||
Rejected: action.SuggestedPostRejected,
|
||||
BalanceTooLow: action.SuggestedPostBalanceTooLow,
|
||||
}
|
||||
if action.SuggestedPostRejectComment != "" {
|
||||
out.SetRejectComment(action.SuggestedPostRejectComment)
|
||||
}
|
||||
if action.SuggestedPostScheduleDate > 0 {
|
||||
out.SetScheduleDate(action.SuggestedPostScheduleDate)
|
||||
}
|
||||
if price := tgSuggestedPostPrice(action.SuggestedPostPrice); price != nil {
|
||||
out.SetPrice(price)
|
||||
}
|
||||
return out
|
||||
case domain.ChannelActionSuggestedPostSuccess:
|
||||
if price := tgSuggestedPostPrice(action.SuggestedPostPrice); price != nil {
|
||||
return &tg.MessageActionSuggestedPostSuccess{Price: price}
|
||||
}
|
||||
return nil
|
||||
case domain.ChannelActionSuggestedPostRefund:
|
||||
return &tg.MessageActionSuggestedPostRefund{PayerInitiated: action.SuggestedPostPayerInitiated}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func tgSuggestedPostPrice(price *domain.SuggestedPostPrice) tg.StarsAmountClass {
|
||||
if price == nil {
|
||||
return nil
|
||||
}
|
||||
switch price.Kind {
|
||||
case domain.SuggestedPostPriceStars:
|
||||
return &tg.StarsAmount{Amount: price.Amount, Nanos: price.Nanos}
|
||||
case domain.SuggestedPostPriceTON:
|
||||
return &tg.StarsTonAmount{Amount: price.Amount}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,14 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// resolveMonoforumForAdmin 解析 parent_peer 指向的 monoforum 虚拟频道,并校验当前用户是其母广播频道
|
||||
// 的管理员/创建者(频道私信只有频道管理员可读/回复)。monoforum 是私有零成员频道,管理员并非其成员,
|
||||
// 故走 store 的 membership-agnostic 解析,在母频道上做授权。
|
||||
// 返回 (monoforum频道, isMonoforum, err):parent 是有效频道但非 monoforum 时返回 (零, false, nil),
|
||||
// 由调用方回退良性空响应(兼容对普通频道传 parent_peer 的被动探测);是 monoforum 但非管理员→CHAT_ADMIN_REQUIRED。
|
||||
// resolveMonoforumForAdmin 解析 TDesktop messages.getSavedDialogs/getSavedHistory 的 parent_peer,
|
||||
// 并校验当前用户可管理母广播频道的 Direct Messages。TDesktop 的 SavedSublist 实际会把
|
||||
// parentChat()->input() 作为 parent_peer;根据客户端 materialize 路径,它既可能是 monoforum
|
||||
// 虚拟频道,也可能是与之关联的母广播频道。因此这里把两种 wire peer 归一到同一个 monoforum,
|
||||
// 授权仍只认母频道的 creator / ManageDirectMessages,绝不能把普通 admin 放进管理者视图。
|
||||
//
|
||||
// 返回 (monoforum频道, isMonoforum, err):parent 是有效但未关联 Direct Messages 的普通频道时
|
||||
// 返回 (零, false, nil),由调用方保留良性空响应;关联频道的非管理者返回 CHAT_ADMIN_REQUIRED。
|
||||
func (r *Router) resolveMonoforumForAdmin(ctx context.Context, userID int64, parent domain.Peer) (domain.Channel, bool, error) {
|
||||
if r.deps.Channels == nil {
|
||||
return domain.Channel{}, false, notImplementedErr()
|
||||
|
|
@ -22,9 +25,30 @@ func (r *Router) resolveMonoforumForAdmin(ctx context.Context, userID int64, par
|
|||
return domain.Channel{}, false, parentPeerInvalidErr()
|
||||
}
|
||||
mono, isAdmin, err := r.deps.Channels.ResolveMonoforumSend(ctx, userID, parent.ID)
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
// TDesktop 当前的 Direct Messages subsection 会传母广播频道。只接受显式的
|
||||
// linked_monoforum 关系,不能把任意普通频道猜成 monoforum。
|
||||
views, viewErr := r.deps.Channels.GetChannels(ctx, userID, []int64{parent.ID})
|
||||
if viewErr != nil {
|
||||
return domain.Channel{}, false, internalErr()
|
||||
}
|
||||
if len(views) != 1 {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
parentChannel := views[0].Channel
|
||||
if parentChannel.ID != parent.ID || parentChannel.Deleted || parentChannel.Monoforum || parentChannel.LinkedMonoforumID == 0 {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
mono, isAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, parentChannel.LinkedMonoforumID)
|
||||
if err != nil {
|
||||
// A visible parent that advertises linked_monoforum_id but cannot resolve
|
||||
// that target violates the durable channel-link invariant. Do not disguise
|
||||
// it as an ordinary channel probe.
|
||||
return domain.Channel{}, false, internalErr()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
// 非 monoforum 频道(或不存在):非错误,交由调用方回退良性空响应。
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
return domain.Channel{}, false, internalErr()
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经
|
||||
// getSavedDialogs(parent_peer=monoforum) 看订阅者子会话列表、经 getSavedHistory 看某订阅者历史
|
||||
// (消息带 saved_peer_id);订阅者经普通 getHistory 只看自己的子会话。
|
||||
// TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经
|
||||
// getSavedDialogs/getSavedHistory 看订阅者子会话,parent_peer 同时兼容 TDesktop 实际发送的
|
||||
// 母广播频道和虚拟 monoforum;订阅者经普通 getHistory 只看自己的子会话。
|
||||
func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
@ -64,6 +64,7 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
t.Fatalf("get monoforum: %v", err)
|
||||
}
|
||||
monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash}
|
||||
parentInput := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||
|
||||
// TDesktop 点 Direct Messages 入口会先按 monoforum peer 拉普通 channel history。
|
||||
// 主历史只应返回 monoforum 自身的 service messages,不能混入 saved_peer 子会话消息。
|
||||
|
|
@ -129,7 +130,8 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
|
||||
// 管理员看私信列表。
|
||||
dreq := &tg.MessagesGetSavedDialogsRequest{}
|
||||
dreq.SetParentPeer(monoInput)
|
||||
// TDesktop SavedSublist::loadAround() 的 parentChat()->input() 是母广播频道。
|
||||
dreq.SetParentPeer(parentInput)
|
||||
dres, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, owner.ID), dreq)
|
||||
if err != nil {
|
||||
t.Fatalf("getSavedDialogs(monoforum): %v", err)
|
||||
|
|
@ -160,7 +162,7 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
|
||||
// 管理员看某订阅者会话历史。
|
||||
hreq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}}
|
||||
hreq.SetParentPeer(monoInput)
|
||||
hreq.SetParentPeer(parentInput)
|
||||
hres, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), hreq)
|
||||
if err != nil {
|
||||
t.Fatalf("getSavedHistory(monoforum): %v", err)
|
||||
|
|
@ -194,6 +196,18 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
|
|||
t.Fatalf("saved_peer_id = %#v, want sub %d", sp, sub.ID)
|
||||
}
|
||||
|
||||
// 虚拟 monoforum peer 仍是合法的等价入口,两个 parent 不能落到不同数据集。
|
||||
directMonoReq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}}
|
||||
directMonoReq.SetParentPeer(monoInput)
|
||||
directMonoRes, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), directMonoReq)
|
||||
if err != nil {
|
||||
t.Fatalf("getSavedHistory(direct monoforum): %v", err)
|
||||
}
|
||||
directMonoSlice, ok := directMonoRes.(*tg.MessagesMessagesSlice)
|
||||
if !ok || len(directMonoSlice.Messages) != 1 {
|
||||
t.Fatalf("getSavedHistory(direct monoforum) = %#v, want same single-message topic", directMonoRes)
|
||||
}
|
||||
|
||||
// 非管理员(订阅者本人)经管理员入口看列表被拒。
|
||||
if _, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, sub.ID), dreq); err == nil {
|
||||
t.Fatalf("non-admin getSavedDialogs(monoforum) = nil err, want denied")
|
||||
|
|
|
|||
|
|
@ -91,6 +91,9 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
|
|||
registerRPC[*tg.MessagesSendMessageRequest](d, tlprofile.SemanticMethodMessagesSendMessage, func(ctx context.Context, layerRequest *tg.MessagesSendMessageRequest) (any, error) {
|
||||
return r.onMessagesSendMessage(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.MessagesToggleSuggestedPostApprovalRequest](d, tlprofile.SemanticMethodMessagesToggleSuggestedPostApproval, func(ctx context.Context, layerRequest *tg.MessagesToggleSuggestedPostApprovalRequest) (any, error) {
|
||||
return r.onMessagesToggleSuggestedPostApproval(ctx, layerRequest)
|
||||
})
|
||||
registerRPC[*tg.MessagesForwardMessagesRequest](d, tlprofile.SemanticMethodMessagesForwardMessages, func(ctx context.Context, layerRequest *tg.MessagesForwardMessagesRequest) (any, error) {
|
||||
return r.onMessagesForwardMessages(ctx, layerRequest)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -13,6 +18,71 @@ const (
|
|||
maxSuggestedPostNanoTON int64 = 10_000_000_000_000
|
||||
)
|
||||
|
||||
const (
|
||||
minSuggestedPostScheduleDelay = 5 * 60
|
||||
maxSuggestedPostScheduleDelay = 31 * 24 * 60 * 60
|
||||
maxSuggestedPostRejectComment = 1024
|
||||
)
|
||||
|
||||
type suggestedPostApprovalService interface {
|
||||
ToggleSuggestedPostApproval(context.Context, domain.ToggleSuggestedPostApprovalRequest) (domain.ToggleSuggestedPostApprovalResult, error)
|
||||
ProcessSuggestedPostLifecycle(context.Context, domain.SuggestedPostLifecycleRequest) ([]domain.ToggleSuggestedPostApprovalResult, error)
|
||||
}
|
||||
|
||||
func (r *Router) onMessagesToggleSuggestedPostApproval(ctx context.Context, req *tg.MessagesToggleSuggestedPostApprovalRequest) (tg.UpdatesClass, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if req == nil || req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
comment, hasComment := req.GetRejectComment()
|
||||
if (!req.Reject && hasComment) || utf8.RuneCountInString(comment) > maxSuggestedPostRejectComment {
|
||||
return nil, tgerr400("SUGGESTED_POST_INVALID")
|
||||
}
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if peer.Type != domain.PeerTypeChannel || peer.ID == 0 {
|
||||
return nil, peerIDInvalidErr()
|
||||
}
|
||||
service, ok := r.deps.Channels.(suggestedPostApprovalService)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
scheduleDate, hasScheduleDate := req.GetScheduleDate()
|
||||
if hasScheduleDate && (req.Reject || scheduleDate < now+minSuggestedPostScheduleDelay || scheduleDate > now+maxSuggestedPostScheduleDelay) {
|
||||
return nil, scheduleDateInvalidErr()
|
||||
}
|
||||
result, err := service.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||
UserID: userID, MonoforumID: peer.ID, MessageID: req.MsgID, Reject: req.Reject,
|
||||
RejectComment: strings.TrimSpace(comment), ScheduleDate: scheduleDate, Date: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, suggestedPostApprovalErr(err)
|
||||
}
|
||||
if !result.Duplicate {
|
||||
r.enqueueSuggestedPostApprovalFanout(ctx, userID, result)
|
||||
}
|
||||
return r.suggestedPostApprovalUpdates(ctx, userID, result), nil
|
||||
}
|
||||
|
||||
func suggestedPostApprovalErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrSuggestedPostApprovalForbidden):
|
||||
return tgerr400("CHAT_ADMIN_REQUIRED")
|
||||
case errors.Is(err, domain.ErrSuggestedPostAlreadyHandled):
|
||||
return tgerr400("SUGGESTED_POST_ALREADY_HANDLED")
|
||||
case errors.Is(err, domain.ErrSuggestedPostInvalid), errors.Is(err, domain.ErrChannelInvalid), errors.Is(err, domain.ErrMessageIDInvalid):
|
||||
return tgerr400("SUGGESTED_POST_INVALID")
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
}
|
||||
|
||||
func domainSuggestedPost(input tg.SuggestedPost, present bool) (*domain.SuggestedPost, error) {
|
||||
if !present {
|
||||
return nil, nil
|
||||
|
|
@ -30,8 +100,7 @@ func domainSuggestedPost(input tg.SuggestedPost, present bool) (*domain.Suggeste
|
|||
if price, ok := input.GetPrice(); ok {
|
||||
switch value := price.(type) {
|
||||
case *tg.StarsAmount:
|
||||
if value == nil || value.Amount < minSuggestedPostStars || value.Amount > maxSuggestedPostStars ||
|
||||
value.Nanos < 0 || value.Nanos >= 1_000_000_000 || value.Amount == maxSuggestedPostStars && value.Nanos != 0 {
|
||||
if value == nil || value.Amount < minSuggestedPostStars || value.Amount > maxSuggestedPostStars || value.Nanos != 0 {
|
||||
return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID")
|
||||
}
|
||||
out.Price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: value.Amount, Nanos: value.Nanos}
|
||||
|
|
|
|||
158
internal/rpc/messages_suggested_post_rpc_test.go
Normal file
158
internal/rpc/messages_suggested_post_rpc_test.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestMessagesToggleSuggestedPostApprovalRegisteredAndProjectsLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 101, Phone: "15551110001", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subscriber, err := users.Create(ctx, domain.User{AccessHash: 102, Phone: "15551110002", FirstName: "Subscriber"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelsStore := memory.NewChannelStore()
|
||||
channels := appchannels.NewService(channelsStore)
|
||||
created, err := channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "Suggested", Broadcast: true, Date: 1_700_000_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, err := channelsStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mono, err := channelsStore.GetChannelByID(ctx, enabled.Channel.LinkedMonoforumID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved := domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID}
|
||||
suggestion, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: saved, RandomID: 91, Message: "RPC suggestion", SuggestedPost: &domain.SuggestedPost{}, Date: 1_700_000_100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := New(Config{}, Deps{Users: appusers.NewService(users), Channels: channels}, zaptest.NewLogger(t), clock.System)
|
||||
req := &tg.MessagesToggleSuggestedPostApprovalRequest{Peer: &tg.InputPeerChannel{ChannelID: mono.ID, AccessHash: mono.AccessHash}, MsgID: suggestion.Message.ID}
|
||||
var raw bin.Buffer
|
||||
if err := req.Encode(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := router.Dispatch(WithLayer(WithUserID(ctx, owner.ID), 228), [8]byte{}, 0, &raw)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch toggleSuggestedPostApproval: %v", err)
|
||||
}
|
||||
updates, ok := response.(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("response=%T, want *tg.Updates", response)
|
||||
}
|
||||
var edited, approval, published bool
|
||||
for _, update := range updates.Updates {
|
||||
switch item := update.(type) {
|
||||
case *tg.UpdateEditChannelMessage:
|
||||
message, ok := item.Message.(*tg.Message)
|
||||
if ok && message.ID == suggestion.Message.ID {
|
||||
post, present := message.GetSuggestedPost()
|
||||
edited = present && post.GetAccepted()
|
||||
}
|
||||
case *tg.UpdateNewChannelMessage:
|
||||
switch message := item.Message.(type) {
|
||||
case *tg.MessageService:
|
||||
action, ok := message.Action.(*tg.MessageActionSuggestedPostApproval)
|
||||
if ok {
|
||||
scheduleDate, hasScheduleDate := action.GetScheduleDate()
|
||||
approval = !action.Rejected && !action.BalanceTooLow && hasScheduleDate && scheduleDate > 0
|
||||
}
|
||||
if savedPeer, present := message.GetSavedPeerID(); !present {
|
||||
t.Fatalf("approval service missing saved_peer_id")
|
||||
} else if peer, ok := savedPeer.(*tg.PeerUser); !ok || peer.UserID != subscriber.ID {
|
||||
t.Fatalf("approval saved_peer=%#v", savedPeer)
|
||||
}
|
||||
case *tg.Message:
|
||||
published = message.PeerID.(*tg.PeerChannel).ChannelID == created.Channel.ID && message.Post && message.Message == "RPC suggestion"
|
||||
}
|
||||
}
|
||||
}
|
||||
if !edited || !approval || !published {
|
||||
t.Fatalf("updates missing edit/approval/publish: %#v", updates.Updates)
|
||||
}
|
||||
|
||||
var retryRaw bin.Buffer
|
||||
if err := req.Encode(&retryRaw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retry, err := router.Dispatch(WithLayer(WithUserID(ctx, owner.ID), 227), [8]byte{}, 0, &retryRaw)
|
||||
if err != nil {
|
||||
t.Fatalf("layer 227 retry: %v", err)
|
||||
}
|
||||
got, ok := retry.(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("layer 227 response=%T", retry)
|
||||
}
|
||||
// Duplicate replay returns the persisted approval + published update to
|
||||
// the caller but is never fanned out again.
|
||||
if len(got.Updates) != 3 {
|
||||
t.Fatalf("layer 227 duplicate updates=%d, want 3", len(got.Updates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostTLProjectionSeparatesSuggestionAndPublishedPaymentFlags(t *testing.T) {
|
||||
original := domain.ChannelMessage{ChannelID: 10, ID: 1, SenderUserID: 20, From: domain.Peer{Type: domain.PeerTypeUser, ID: 20}, SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 20}, Date: 100, Body: "proposal", SuggestedPost: &domain.SuggestedPost{Accepted: true, Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}}
|
||||
proposal := tgChannelMessage(20, original).(*tg.Message)
|
||||
if _, present := proposal.GetSuggestedPost(); !present || proposal.GetPaidSuggestedPostStars() {
|
||||
t.Fatalf("proposal flags=%+v", proposal)
|
||||
}
|
||||
published := original
|
||||
published.ChannelID, published.ID, published.Post, published.SavedPeer = 11, 2, true, domain.Peer{}
|
||||
post := tgChannelMessage(20, published).(*tg.Message)
|
||||
if !post.GetPaidSuggestedPostStars() {
|
||||
t.Fatalf("published Stars post missing paid flag")
|
||||
}
|
||||
if _, present := post.GetSuggestedPost(); present {
|
||||
t.Fatalf("published post leaked suggested_post")
|
||||
}
|
||||
published.SuggestedPost.Price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceTON, Amount: 10_000_000}
|
||||
ton := tgChannelMessage(20, published).(*tg.Message)
|
||||
if !ton.GetPaidSuggestedPostTon() || ton.GetPaidSuggestedPostStars() {
|
||||
t.Fatalf("published TON flags=%+v", ton)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostApprovalScheduleDateSurvivesExactProfiles(t *testing.T) {
|
||||
action := tgChannelMessageAction(domain.ChannelMessageAction{
|
||||
Type: domain.ChannelActionSuggestedPostApproval,
|
||||
SuggestedPostScheduleDate: 1_700_000_200,
|
||||
})
|
||||
for _, profile := range []tlprofile.Profile{tlprofile.Profile227, tlprofile.Profile228} {
|
||||
wire := &bin.Buffer{}
|
||||
if err := tlprofile.EncodeObject(profile, action, wire); err != nil {
|
||||
t.Fatalf("encode Layer %d approval action: %v", profile, err)
|
||||
}
|
||||
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Copy()}, tlprofile.Limits{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode Layer %d approval action: %v", profile, err)
|
||||
}
|
||||
decoded, ok := decodedObject.(*tg.MessageActionSuggestedPostApproval)
|
||||
if !ok {
|
||||
t.Fatalf("decode Layer %d approval action = %T", profile, decodedObject)
|
||||
}
|
||||
date, present := decoded.GetScheduleDate()
|
||||
if !present || date != 1_700_000_200 {
|
||||
t.Fatalf("Layer %d approval date=%d/%v, want 1700000200/true", profile, date, present)
|
||||
}
|
||||
}
|
||||
}
|
||||
65
internal/rpc/messages_suggested_post_updates.go
Normal file
65
internal/rpc/messages_suggested_post_updates.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (r *Router) suggestedPostApprovalUpdates(ctx context.Context, viewerUserID int64, result domain.ToggleSuggestedPostApprovalResult) *tg.Updates {
|
||||
updates := make([]tg.UpdateClass, 0, 4)
|
||||
if result.OriginalEvent.Pts > 0 {
|
||||
if update := tgChannelUpdate(viewerUserID, result.OriginalEvent); update != nil {
|
||||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
if result.ServiceEvent.Pts > 0 {
|
||||
if update := tgChannelUpdate(viewerUserID, result.ServiceEvent); update != nil {
|
||||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
if result.Published != nil && result.Published.Event.Pts > 0 {
|
||||
if update := tgChannelUpdate(viewerUserID, result.Published.Event); update != nil {
|
||||
updates = append(updates, update)
|
||||
}
|
||||
}
|
||||
if result.PayerStarsBalance != nil && result.PayerStarsBalance.UserID == viewerUserID {
|
||||
updates = append(updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.PayerStarsBalance.Balance}})
|
||||
}
|
||||
chats := r.monoforumChats(ctx, viewerUserID, result.Monoforum)
|
||||
if result.Parent.ID != 0 {
|
||||
chats = appendUniqueTGChats(chats, tgChannelChatMin(viewerUserID, result.Parent))
|
||||
}
|
||||
messages := make([]domain.ChannelMessage, 0, 3)
|
||||
if result.OriginalMessage.ID != 0 {
|
||||
messages = append(messages, result.OriginalMessage)
|
||||
}
|
||||
if result.ServiceMessage.ID != 0 {
|
||||
messages = append(messages, result.ServiceMessage)
|
||||
}
|
||||
if result.Published != nil {
|
||||
messages = append(messages, result.Published.Message)
|
||||
}
|
||||
return &tg.Updates{
|
||||
Updates: updates,
|
||||
Chats: chats,
|
||||
Users: r.monoforumSubscriberUsers(ctx, viewerUserID, []domain.MonoforumDialog{{SavedPeer: result.SavedPeer}}, messages),
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) enqueueSuggestedPostApprovalFanout(ctx context.Context, originUserID int64, result domain.ToggleSuggestedPostApprovalResult) {
|
||||
monoOnly := result
|
||||
monoOnly.Published = nil
|
||||
nudge := max(result.OriginalEvent.Pts, result.ServiceEvent.Pts)
|
||||
if nudge > 0 {
|
||||
r.enqueueChannelFanout(ctx, channelFanoutExplicit, originUserID, result.Monoforum.ID, nudge, result.Recipients, func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
|
||||
return r.suggestedPostApprovalUpdates(bgCtx, viewerUserID, monoOnly)
|
||||
})
|
||||
}
|
||||
if result.Published != nil && result.Published.Event.Pts > 0 {
|
||||
r.enqueueChannelMessageFanout(ctx, originUserID, *result.Published, nil)
|
||||
}
|
||||
}
|
||||
59
internal/rpc/suggested_post_dispatcher.go
Normal file
59
internal/rpc/suggested_post_dispatcher.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// SuggestedPostDispatcher publishes scheduled suggestions and resolves paid
|
||||
// escrow after the minimum live age (or refunds it when the post is deleted).
|
||||
// Store-side row locks make multiple server instances safe.
|
||||
type SuggestedPostDispatcher struct {
|
||||
router *Router
|
||||
log *zap.Logger
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
func NewSuggestedPostDispatcher(router *Router, log *zap.Logger) *SuggestedPostDispatcher {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &SuggestedPostDispatcher{router: router, log: log, interval: time.Second, batch: 50}
|
||||
}
|
||||
|
||||
func (d *SuggestedPostDispatcher) Run(ctx context.Context) {
|
||||
if d == nil || d.router == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(d.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
d.DispatchOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SuggestedPostDispatcher) DispatchOnce(ctx context.Context) bool {
|
||||
service, ok := d.router.deps.Channels.(suggestedPostApprovalService)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
results, err := service.ProcessSuggestedPostLifecycle(ctx, domain.SuggestedPostLifecycleRequest{Now: int(d.router.clock.Now().Unix()), Limit: d.batch})
|
||||
if err != nil {
|
||||
d.log.Warn("process suggested post lifecycle", zap.Error(err))
|
||||
return false
|
||||
}
|
||||
for _, result := range results {
|
||||
d.router.enqueueSuggestedPostApprovalFanout(ctx, 0, result)
|
||||
}
|
||||
return len(results) > 0
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue