fix: sync stars topup and paid reaction settings
This commit is contained in:
parent
4e18d31e16
commit
1f11b0fd1b
12 changed files with 506 additions and 24 deletions
|
|
@ -178,6 +178,9 @@ func TestBroadcastChannelAcceptsFullReactionCatalog(t *testing.T) {
|
|||
if fullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("full channel paid reactions = true, want false without paid_enabled flag")
|
||||
}
|
||||
if !fullChannel.GetPaidMediaAllowed() {
|
||||
t.Fatalf("broadcast full channel paid_media_allowed = false, want true for Android paid reaction editor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
|
||||
|
|
@ -253,6 +256,159 @@ func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
|
|||
if fullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("paid reactions after explicit false = true, want false")
|
||||
}
|
||||
if !fullChannel.GetPaidMediaAllowed() {
|
||||
t.Fatalf("broadcast paid_media_allowed after paid disable = false, want capability preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetChatAvailableReactionsStripsTDesktopPaidSentinel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 101, Phone: "15550002204", FirstName: "Owner"})
|
||||
channelStore := memory.NewChannelStore()
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: appchannels.NewService(channelStore),
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
|
||||
Title: "TDesktop Paid Sentinel",
|
||||
Broadcast: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create broadcast channel: %v", err)
|
||||
}
|
||||
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||||
|
||||
tdesktopReq := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||
Peer: peer,
|
||||
AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{
|
||||
&tg.ReactionPaid{},
|
||||
&tg.ReactionEmoji{Emoticon: "\U0001f44d"},
|
||||
&tg.ReactionEmoji{Emoticon: "\u2764"},
|
||||
&tg.ReactionPaid{},
|
||||
}},
|
||||
}
|
||||
tdesktopReq.SetReactionsLimit(11)
|
||||
tdesktopReq.SetPaidEnabled(true)
|
||||
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), tdesktopReq); err != nil {
|
||||
t.Fatalf("set TDesktop paid sentinel reactions: %v", err)
|
||||
}
|
||||
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get full channel after TDesktop sentinel set: %v", err)
|
||||
}
|
||||
fullChannel := full.FullChat.(*tg.ChannelFull)
|
||||
if !fullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("paid reactions after TDesktop sentinel set = false, want true")
|
||||
}
|
||||
some := mustChannelFullSomeReactions(t, full)
|
||||
if len(some.Reactions) != 2 {
|
||||
t.Fatalf("stored reactions after stripping sentinel = %d, want 2", len(some.Reactions))
|
||||
}
|
||||
for i, reaction := range some.Reactions {
|
||||
if _, ok := reaction.(*tg.ReactionPaid); ok {
|
||||
t.Fatalf("stored reaction[%d] = reactionPaid, want paid state only in paid_reactions_available", i)
|
||||
}
|
||||
}
|
||||
|
||||
fullCatalog := make([]tg.ReactionClass, 0, domain.MaxChannelReactionTypes+1)
|
||||
fullCatalog = append(fullCatalog, &tg.ReactionPaid{})
|
||||
for i := 0; i < domain.MaxChannelReactionTypes; i++ {
|
||||
fullCatalog = append(fullCatalog, &tg.ReactionEmoji{Emoticon: fmt.Sprintf("r%03d", i)})
|
||||
}
|
||||
maxReq := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||
Peer: peer,
|
||||
AvailableReactions: &tg.ChatReactionsSome{Reactions: fullCatalog},
|
||||
}
|
||||
maxReq.SetPaidEnabled(true)
|
||||
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), maxReq); err != nil {
|
||||
t.Fatalf("set max normal reactions plus paid sentinel: %v", err)
|
||||
}
|
||||
full, err = r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get full channel after max sentinel set: %v", err)
|
||||
}
|
||||
some = mustChannelFullSomeReactions(t, full)
|
||||
if len(some.Reactions) != domain.MaxChannelReactionTypes {
|
||||
t.Fatalf("stored reactions after max sentinel set = %d, want %d", len(some.Reactions), domain.MaxChannelReactionTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelFullPaidReactionCapabilityOnlyBroadcast(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 101, Phone: "15550002202", FirstName: "Owner"})
|
||||
channelStore := memory.NewChannelStore()
|
||||
r := New(Config{}, Deps{
|
||||
Users: appusers.NewService(userStore),
|
||||
Channels: appchannels.NewService(channelStore),
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
broadcastCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
|
||||
Title: "Paid Reaction Broadcast",
|
||||
Broadcast: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create broadcast channel: %v", err)
|
||||
}
|
||||
broadcast := broadcastCreated.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||
broadcastFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get broadcast full channel: %v", err)
|
||||
}
|
||||
broadcastFullChannel := broadcastFull.FullChat.(*tg.ChannelFull)
|
||||
if !broadcastFullChannel.GetPaidMediaAllowed() {
|
||||
t.Fatalf("broadcast paid_media_allowed = false, want true")
|
||||
}
|
||||
if broadcastFullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("broadcast paid_reactions_available = true before paid_enabled, want false")
|
||||
}
|
||||
|
||||
enablePaid := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||
Peer: &tg.InputPeerChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash},
|
||||
AvailableReactions: &tg.ChatReactionsAll{},
|
||||
}
|
||||
enablePaid.SetPaidEnabled(true)
|
||||
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), enablePaid); err != nil {
|
||||
t.Fatalf("enable broadcast paid reactions: %v", err)
|
||||
}
|
||||
broadcastFull, err = r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get broadcast full channel after paid enable: %v", err)
|
||||
}
|
||||
broadcastFullChannel = broadcastFull.FullChat.(*tg.ChannelFull)
|
||||
if !broadcastFullChannel.GetPaidMediaAllowed() || !broadcastFullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("broadcast flags after enable: paid_media_allowed=%v paid_reactions_available=%v, want both true",
|
||||
broadcastFullChannel.GetPaidMediaAllowed(), broadcastFullChannel.GetPaidReactionsAvailable())
|
||||
}
|
||||
|
||||
megaCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
|
||||
Title: "Paid Reaction Mega",
|
||||
Megagroup: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create megagroup: %v", err)
|
||||
}
|
||||
mega := megaCreated.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||
enableMegaPaid := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||
Peer: &tg.InputPeerChannel{ChannelID: mega.ID, AccessHash: mega.AccessHash},
|
||||
AvailableReactions: &tg.ChatReactionsAll{},
|
||||
}
|
||||
enableMegaPaid.SetPaidEnabled(true)
|
||||
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), enableMegaPaid); err != nil {
|
||||
t.Fatalf("set megagroup paid_enabled request: %v", err)
|
||||
}
|
||||
megaFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: mega.ID, AccessHash: mega.AccessHash})
|
||||
if err != nil {
|
||||
t.Fatalf("get megagroup full channel: %v", err)
|
||||
}
|
||||
megaFullChannel := megaFull.FullChat.(*tg.ChannelFull)
|
||||
if megaFullChannel.GetPaidMediaAllowed() || megaFullChannel.GetPaidReactionsAvailable() {
|
||||
t.Fatalf("megagroup flags: paid_media_allowed=%v paid_reactions_available=%v, want both false",
|
||||
megaFullChannel.GetPaidMediaAllowed(), megaFullChannel.GetPaidReactionsAvailable())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndroidChannelReactionEditorProjectsDefaultEmojiAsDocuments(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -107,12 +107,16 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques
|
|||
policy.Type = domain.ChannelReactionPolicyAll
|
||||
policy.AllowCustom = reactions.AllowCustom
|
||||
case *tg.ChatReactionsSome:
|
||||
if len(reactions.Reactions) > domain.MaxChannelReactionTypes {
|
||||
return domain.ChannelReactionPolicy{}, limitInvalidErr()
|
||||
}
|
||||
policy.Type = domain.ChannelReactionPolicySome
|
||||
seen := make(map[string]struct{}, len(reactions.Reactions))
|
||||
for _, reaction := range reactions.Reactions {
|
||||
// TDesktop models the paid toggle as a pseudo reaction in the
|
||||
// selector and may submit reactionPaid in chatReactionsSome. The
|
||||
// durable paid state is carried by paid_enabled, not the normal
|
||||
// reaction whitelist.
|
||||
if paidReactionSentinel(reaction) {
|
||||
continue
|
||||
}
|
||||
parsed, err := domainMessageReactionFromTL(reaction)
|
||||
if err != nil {
|
||||
return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID")
|
||||
|
|
@ -122,6 +126,9 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques
|
|||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
if len(seen) >= domain.MaxChannelReactionTypes {
|
||||
return domain.ChannelReactionPolicy{}, limitInvalidErr()
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
switch parsed.Type {
|
||||
case domain.MessageReactionEmoji:
|
||||
|
|
@ -135,3 +142,8 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques
|
|||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func paidReactionSentinel(reaction tg.ReactionClass) bool {
|
||||
paid, ok := reaction.(*tg.ReactionPaid)
|
||||
return ok && paid != nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -569,15 +569,19 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
|
|||
if ch.ReactionPolicy.Limit > 0 {
|
||||
full.SetReactionsLimit(ch.ReactionPolicy.Limit)
|
||||
}
|
||||
if ch.Broadcast && !ch.Megagroup {
|
||||
// Android uses paid_media_allowed as the capability gate for showing the
|
||||
// paid-reaction setting; paid_reactions_available below remains the saved
|
||||
// on/off state.
|
||||
full.SetPaidMediaAllowed(true)
|
||||
full.SetStargiftsAvailable(true)
|
||||
}
|
||||
// paid_reactions_available reflects the saved chat policy, not mere broadcast
|
||||
// capability. Android counts this flag as an extra available reaction in the
|
||||
// settings row, so advertising it without paid_enabled corrupts the UI count.
|
||||
if ch.ReactionPolicy.PaidEnabled {
|
||||
if ch.Broadcast && !ch.Megagroup && ch.ReactionPolicy.PaidEnabled {
|
||||
full.SetPaidReactionsAvailable(true)
|
||||
}
|
||||
if ch.Broadcast && !ch.Megagroup {
|
||||
full.SetStargiftsAvailable(true)
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,10 @@ func balanceTooLowErr() error { return tgerr.New(400, "BALANCE_TOO_LOW") }
|
|||
|
||||
func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") }
|
||||
|
||||
func starsFormAmountMismatchErr() error { return tgerr.New(406, "STARS_FORM_AMOUNT_MISMATCH") }
|
||||
|
||||
func formIDEmptyErr() error { return tgerr.New(400, "FORM_ID_EMPTY") }
|
||||
|
||||
func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") }
|
||||
|
||||
func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") }
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import (
|
|||
// gift/auction/revenue 第一阶段兼容桩。
|
||||
func (r *Router) registerPayments(d *tg.ServerDispatcher) {
|
||||
d.OnPaymentsGetStarsTopupOptions(func(ctx context.Context) ([]tg.StarsTopupOption, error) {
|
||||
return []tg.StarsTopupOption{}, nil
|
||||
return devStarsTopupOptions(), nil
|
||||
})
|
||||
// premium 订阅赠送 telesrv 不实现(无支付流),返回空选项。关键作用:TDesktop 送礼框
|
||||
// ShowStarGiftBox 的 ready() 门控要求 getPremiumGiftCodeOptions 成功返回(on_next)才置
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ import (
|
|||
|
||||
func starGiftInvalidErr() error { return tgerr.New(400, "STARGIFT_INVALID") }
|
||||
|
||||
func devStarsTopupOptions() []tg.StarsTopupOption {
|
||||
return []tg.StarsTopupOption{
|
||||
{Stars: 1000, Currency: "USD", Amount: 99},
|
||||
{Stars: 2500, Currency: "USD", Amount: 199},
|
||||
{Stars: 5000, Currency: "USD", Amount: 399},
|
||||
}
|
||||
}
|
||||
|
||||
// onPaymentsGetStarGifts 返回可购买礼物目录(hash 命中返回 NotModified)。
|
||||
func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) {
|
||||
if r.deps.Gifts == nil {
|
||||
|
|
@ -41,13 +49,35 @@ func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.Payme
|
|||
}, nil
|
||||
}
|
||||
|
||||
// onPaymentsGetPaymentForm 仅处理 inputInvoiceStarGift:返回 paymentFormStarGift。
|
||||
// onPaymentsGetPaymentForm 处理 Stars 专用 invoice:
|
||||
// - inputInvoiceStarGift 返回 paymentFormStarGift。
|
||||
// - inputInvoiceStars(inputStorePaymentStarsTopup) 返回 paymentFormStars。
|
||||
//
|
||||
// 崩溃约束:star gift invoice 必须返 paymentFormStarGift#b425cfe1(TDesktop 单分支 match),
|
||||
// Invoice.Prices 必须非空(DrKLO/TDesktop 读 prices.front())。
|
||||
// Stars 表单 Invoice.Prices 必须非空且 Currency=XTR(DrKLO/TDesktop 读 prices.front())。
|
||||
func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsGetPaymentFormRequest) (tg.PaymentsPaymentFormClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
|
||||
purpose, ok := starsTopupPurpose(inv)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
if r.deps.Stars == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
if _, _, err := r.validateStarsTopupPurpose(ctx, userID, purpose); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.starsTopupPaymentForm(userID, purpose), nil
|
||||
}
|
||||
|
||||
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
|
|
@ -55,10 +85,6 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
|
|||
if r.deps.Gifts == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -75,21 +101,29 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
|
|||
}, nil
|
||||
}
|
||||
|
||||
// onPaymentsSendStarsForm 仅处理 inputInvoiceStarGift:Debit→投递/记账,
|
||||
// 返回 paymentResult{updates}(含 updateStarsBalance;用户礼物还含私聊服务消息)。失败补偿退款。
|
||||
// onPaymentsSendStarsForm 处理 star gift 与 Stars topup:
|
||||
// - star gift: Debit→投递/记账,失败补偿退款。
|
||||
// - topup: 校验测试包白名单→Credit 本地账本。
|
||||
//
|
||||
// 返回 paymentResult{updates}(含 updateStarsBalance;用户礼物还含私聊服务消息)。
|
||||
// 崩溃约束:必须返回合法 paymentResult{非空 Updates}(DrKLO 强转)。
|
||||
func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSendStarsFormRequest) (tg.PaymentsPaymentResultClass, error) {
|
||||
if req == nil {
|
||||
return nil, inputRequestInvalidErr()
|
||||
}
|
||||
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
|
||||
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
|
||||
return r.sendStarsTopupForm(ctx, userID, req.FormID, inv)
|
||||
}
|
||||
|
||||
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -149,6 +183,83 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
|
|||
return &tg.PaymentsPaymentResult{Updates: updates}, nil
|
||||
}
|
||||
|
||||
func starsTopupPurpose(inv *tg.InputInvoiceStars) (*tg.InputStorePaymentStarsTopup, bool) {
|
||||
if inv == nil {
|
||||
return nil, false
|
||||
}
|
||||
purpose, ok := inv.Purpose.(*tg.InputStorePaymentStarsTopup)
|
||||
return purpose, ok && purpose != nil
|
||||
}
|
||||
|
||||
func (r *Router) validateStarsTopupPurpose(ctx context.Context, userID int64, purpose *tg.InputStorePaymentStarsTopup) (tg.StarsTopupOption, domain.Peer, error) {
|
||||
if purpose == nil || purpose.Stars <= 0 || purpose.Amount <= 0 || purpose.Currency == "" {
|
||||
return tg.StarsTopupOption{}, domain.Peer{}, starsAmountInvalidErr()
|
||||
}
|
||||
var matched tg.StarsTopupOption
|
||||
found := false
|
||||
for _, opt := range devStarsTopupOptions() {
|
||||
if opt.Stars == purpose.Stars && opt.Currency == purpose.Currency && opt.Amount == purpose.Amount {
|
||||
matched = opt
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return tg.StarsTopupOption{}, domain.Peer{}, starsFormAmountMismatchErr()
|
||||
}
|
||||
peer := domain.Peer{}
|
||||
if purpose.SpendPurposePeer != nil {
|
||||
var err error
|
||||
peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, purpose.SpendPurposePeer)
|
||||
if err != nil {
|
||||
return tg.StarsTopupOption{}, domain.Peer{}, err
|
||||
}
|
||||
}
|
||||
return matched, peer, nil
|
||||
}
|
||||
|
||||
func (r *Router) starsTopupPaymentForm(userID int64, purpose *tg.InputStorePaymentStarsTopup) *tg.PaymentsPaymentFormStars {
|
||||
return &tg.PaymentsPaymentFormStars{
|
||||
FormID: starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount),
|
||||
BotID: domain.OfficialSystemUserID,
|
||||
Title: "Telegram Stars",
|
||||
Description: "telesrv dev Stars top-up",
|
||||
Invoice: tg.Invoice{
|
||||
Currency: "XTR",
|
||||
Prices: []tg.LabeledPrice{{Label: "Telegram Stars", Amount: purpose.Stars}},
|
||||
},
|
||||
Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()}),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStars) (tg.PaymentsPaymentResultClass, error) {
|
||||
purpose, ok := starsTopupPurpose(inv)
|
||||
if !ok {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
if formID == 0 {
|
||||
return nil, formIDEmptyErr()
|
||||
}
|
||||
if r.deps.Stars == nil {
|
||||
return nil, notImplementedErr()
|
||||
}
|
||||
_, peer, err := r.validateStarsTopupPurpose(ctx, userID, purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if formID != starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount) {
|
||||
return nil, starsFormAmountMismatchErr()
|
||||
}
|
||||
if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
balance, err := r.deps.Stars.Credit(ctx, userID, purpose.Stars, domain.StarsReasonTopup, peer, "Stars top-up", "telesrv dev purchase")
|
||||
if err != nil {
|
||||
return nil, starsErr(err)
|
||||
}
|
||||
return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(balance.Balance, r.clock.Now().Unix())}, nil
|
||||
}
|
||||
|
||||
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) {
|
||||
// 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。
|
||||
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message)
|
||||
|
|
@ -668,6 +779,29 @@ func starGiftFormID(userID, giftID int64) int64 {
|
|||
return id
|
||||
}
|
||||
|
||||
func starsTopupFormID(userID, stars int64, currency string, amount int64) int64 {
|
||||
id := userID*0x9e3779b1 ^ (stars << 7) ^ (amount << 13) ^ 0x5354415253
|
||||
for _, ch := range currency {
|
||||
id = id*131 + int64(ch)
|
||||
}
|
||||
if id < 0 {
|
||||
id = ^id
|
||||
}
|
||||
if id == 0 {
|
||||
id = 0x5354
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func starsBalanceUpdates(balance int64, unixDate int64) *tg.Updates {
|
||||
return &tg.Updates{
|
||||
Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance}}},
|
||||
Users: []tg.UserClass{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Date: int(unixDate),
|
||||
}
|
||||
}
|
||||
|
||||
func giftPriceLabel(g domain.StarGift) string {
|
||||
if g.Title != "" {
|
||||
return g.Title
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appchannels "telesrv/internal/app/channels"
|
||||
|
|
@ -383,3 +384,95 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
|
|||
t.Fatalf("sender balance = %d, want 1000 unchanged", bal.Balance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarsTopupInvoiceFallbackCreditsBalance(t *testing.T) {
|
||||
r, sender, _, _ := starGiftTestRouter(t)
|
||||
ctx := context.Background()
|
||||
senderCtx := WithUserID(ctx, sender.ID)
|
||||
opt := devStarsTopupOptions()[1]
|
||||
inv := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{
|
||||
Stars: opt.Stars,
|
||||
Currency: opt.Currency,
|
||||
Amount: opt.Amount,
|
||||
}}
|
||||
|
||||
formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
|
||||
if err != nil {
|
||||
t.Fatalf("getPaymentForm topup: %v", err)
|
||||
}
|
||||
form, ok := formRes.(*tg.PaymentsPaymentFormStars)
|
||||
if !ok {
|
||||
t.Fatalf("form = %T, want *tg.PaymentsPaymentFormStars", formRes)
|
||||
}
|
||||
if form.FormID != starsTopupFormID(sender.ID, opt.Stars, opt.Currency, opt.Amount) {
|
||||
t.Fatalf("form id = %d, want deterministic topup id", form.FormID)
|
||||
}
|
||||
if form.BotID != domain.OfficialSystemUserID || len(form.Users) != 1 {
|
||||
t.Fatalf("form bot/users = %d/%d, want official system user", form.BotID, len(form.Users))
|
||||
}
|
||||
if form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != opt.Stars {
|
||||
t.Fatalf("form invoice = %+v, want XTR + 1 price %d", form.Invoice, opt.Stars)
|
||||
}
|
||||
|
||||
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID + 1, Invoice: inv}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("sendStarsForm bad form err = %v, want STARS_FORM_AMOUNT_MISMATCH", err)
|
||||
}
|
||||
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 1000 {
|
||||
t.Fatalf("balance after bad form = %d, want 1000 unchanged", bal.Balance)
|
||||
}
|
||||
|
||||
payRes, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv})
|
||||
if err != nil {
|
||||
t.Fatalf("sendStarsForm topup: %v", err)
|
||||
}
|
||||
pay, ok := payRes.(*tg.PaymentsPaymentResult)
|
||||
if !ok {
|
||||
t.Fatalf("pay result = %T, want *tg.PaymentsPaymentResult", payRes)
|
||||
}
|
||||
updates, ok := pay.Updates.(*tg.Updates)
|
||||
if !ok {
|
||||
t.Fatalf("pay updates = %T, want *tg.Updates", pay.Updates)
|
||||
}
|
||||
foundBalance := false
|
||||
for _, up := range updates.Updates {
|
||||
if balance, ok := up.(*tg.UpdateStarsBalance); ok {
|
||||
foundBalance = true
|
||||
if amt, ok := balance.Balance.(*tg.StarsAmount); !ok || amt.Amount != 3500 {
|
||||
t.Fatalf("updateStarsBalance = %#v, want 3500", balance.Balance)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundBalance {
|
||||
t.Fatalf("payment updates missing updateStarsBalance: %#v", updates.Updates)
|
||||
}
|
||||
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 3500 {
|
||||
t.Fatalf("balance after topup = %d, want 3500", bal.Balance)
|
||||
}
|
||||
page, err := r.deps.Stars.ListTransactions(ctx, sender.ID, "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list transactions: %v", err)
|
||||
}
|
||||
hasTopup := false
|
||||
for _, tx := range page.Transactions {
|
||||
if tx.Reason == domain.StarsReasonTopup && tx.Amount == opt.Stars {
|
||||
hasTopup = true
|
||||
}
|
||||
}
|
||||
if !hasTopup {
|
||||
t.Fatalf("transactions missing topup %d: %+v", opt.Stars, page.Transactions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarsTopupRejectsUnlistedAmount(t *testing.T) {
|
||||
r, sender, _, _ := starGiftTestRouter(t)
|
||||
ctx := WithUserID(context.Background(), sender.ID)
|
||||
inv := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{
|
||||
Stars: 2501,
|
||||
Currency: "USD",
|
||||
Amount: 199,
|
||||
}}
|
||||
_, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
|
||||
if !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||
t.Fatalf("getPaymentForm unlisted err = %v, want STARS_FORM_AMOUNT_MISMATCH", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue