feat(stars): sync implement unified purchase flow

This commit is contained in:
iamxvbaba 2026-08-02 01:51:03 +08:00
parent c13fbf8885
commit 7e0f9d1e62
26 changed files with 2087 additions and 282 deletions

View file

@ -135,6 +135,25 @@ func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass {
return &tg.MessageMediaEmpty{}
}
return tgWebPageMedia(*m.WebPage)
case domain.MessageMediaKindGiveaway:
if m.Giveaway == nil {
return &tg.MessageMediaEmpty{}
}
out := &tg.MessageMediaGiveaway{
OnlyNewSubscribers: m.Giveaway.OnlyNewSubscribers,
WinnersAreVisible: m.Giveaway.WinnersAreVisible,
Channels: append([]int64(nil), m.Giveaway.Channels...),
Quantity: m.Giveaway.Quantity,
UntilDate: m.Giveaway.UntilDate,
}
if m.Giveaway.CountriesISO2 != nil {
out.SetCountriesISO2(append([]string(nil), m.Giveaway.CountriesISO2...))
}
if m.Giveaway.PrizeDescription != "" {
out.SetPrizeDescription(m.Giveaway.PrizeDescription)
}
out.SetStars(m.Giveaway.Stars)
return out
default:
return &tg.MessageMediaEmpty{}
}

View file

@ -6,6 +6,7 @@ import (
"strconv"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/compat/tdesktop"
@ -15,9 +16,21 @@ import (
// registerPayments 注册 payments.* RPCStars 本地账本(余额/流水真实化)+ 其余
// gift/auction/revenue 第一阶段兼容桩。
func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
registerRPC[*tg.PaymentsCanPurchaseStoreRequest](d, tlprofile.SemanticMethodPaymentsCanPurchaseStore, func(ctx context.Context, req *tg.PaymentsCanPurchaseStoreRequest) (any, error) {
return r.onPaymentsCanPurchaseStore(ctx, req)
})
registerRPC[*tg.PaymentsAssignPlayMarketTransactionRequest](d, tlprofile.SemanticMethodPaymentsAssignPlayMarketTransaction, func(ctx context.Context, req *tg.PaymentsAssignPlayMarketTransactionRequest) (any, error) {
return r.onPaymentsAssignPlayMarketTransaction(ctx, req)
})
registerRPC[*tg.PaymentsGetStarsGiftOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsGiftOptions, func(ctx context.Context, req *tg.PaymentsGetStarsGiftOptionsRequest) (any, error) {
return r.onPaymentsGetStarsGiftOptions(ctx, req)
})
registerRPC[*tg.PaymentsGetStarsGiveawayOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsGiveawayOptions, func(ctx context.Context, _ *tg.PaymentsGetStarsGiveawayOptionsRequest) (any, error) {
return r.onPaymentsGetStarsGiveawayOptions(ctx)
})
registerRPC[*tg.PaymentsGetGiveawayInfoRequest](d, tlprofile.SemanticMethodPaymentsGetGiveawayInfo, func(ctx context.Context, req *tg.PaymentsGetGiveawayInfoRequest) (any, error) {
return r.onPaymentsGetGiveawayInfo(ctx, req)
})
registerRPC[*tg.PaymentsGetStarsTopupOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTopupOptions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTopupOptionsRequest) (any,
// premium 订阅赠送 telesrv 不实现无支付流返回空选项。关键作用TDesktop 送礼框
@ -33,6 +46,9 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
registerRPC[*tg.PaymentsGetStarsStatusRequest](d, tlprofile.SemanticMethodPaymentsGetStarsStatus, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsStatusRequest) (any, error) {
return r.onPaymentsGetStarsStatus(ctx, layerRequest)
})
registerRPC[*tg.PaymentsGetStarsSubscriptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsSubscriptions, func(ctx context.Context, req *tg.PaymentsGetStarsSubscriptionsRequest) (any, error) {
return r.onPaymentsGetStarsSubscriptions(ctx, req)
})
registerRPC[*tg.PaymentsGetStarsTransactionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTransactions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTransactionsRequest) (any, error) {
return r.onPaymentsGetStarsTransactions(ctx, layerRequest)
})
@ -66,6 +82,9 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
registerRPC[*tg.PaymentsGetPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsGetPaymentForm, func(ctx context.Context, layerRequest *tg.PaymentsGetPaymentFormRequest) (any, error) {
return r.onPaymentsGetPaymentForm(ctx, layerRequest)
})
registerRPC[*tg.PaymentsValidateRequestedInfoRequest](d, tlprofile.SemanticMethodPaymentsValidateRequestedInfo, func(ctx context.Context, req *tg.PaymentsValidateRequestedInfoRequest) (any, error) {
return r.onPaymentsValidateRequestedInfo(ctx, req)
})
registerRPC[*tg.PaymentsSendStarsFormRequest](d, tlprofile.SemanticMethodPaymentsSendStarsForm, func(ctx context.Context, layerRequest *tg.PaymentsSendStarsFormRequest) (any, error) {
return r.onPaymentsSendStarsForm(ctx, layerRequest)
})
@ -157,6 +176,24 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
}
func (r *Router) onPaymentsCanPurchaseStore(ctx context.Context, _ *tg.PaymentsCanPurchaseStoreRequest) (bool, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return false, internalErr()
}
// telesrv deliberately exposes no Google Play products or receipt verifier.
// DrKLO is steered to the invoice flow by appConfig; if a stale client still
// reaches this preflight, fail closed instead of authorizing an unverifiable
// external charge.
return false, nil
}
func (r *Router) onPaymentsAssignPlayMarketTransaction(ctx context.Context, _ *tg.PaymentsAssignPlayMarketTransactionRequest) (tg.UpdatesClass, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
return nil, tgerr.New(400, "STORE_PAYMENT_UNAVAILABLE")
}
// onPaymentsGetStarsRevenueStats exposes real channel Star Gift proceeds from
// the same peer-scoped ledger as getStarsStatus/getStarsTransactions. Personal
// and bot revenue remain the bounded compatibility response because their
@ -269,6 +306,31 @@ func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsG
return emptyStarsStatus(&tg.StarsAmount{Amount: bal.Balance}), nil
}
// onPaymentsGetStarsSubscriptions returns the authoritative current balance
// with an empty subscription page. telesrv does not create recurring Stars
// subscriptions yet; returning a well-shaped terminal page lets both official
// clients finish loading the Stars screen without inventing subscription state.
func (r *Router) onPaymentsGetStarsSubscriptions(ctx context.Context, req *tg.PaymentsGetStarsSubscriptionsRequest) (*tg.PaymentsStarsStatus, error) {
if req == nil || len(req.Offset) > domain.MaxStarsTransactionsOffsetBytes {
return nil, inputRequestInvalidErr()
}
userID, owner, err := r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
if err != nil {
return nil, err
}
if owner.Type != domain.PeerTypeUser || owner.ID != userID {
return nil, peerIDInvalidErr()
}
if r.deps.Stars == nil {
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
balance, err := r.deps.Stars.GetBalance(ctx, userID)
if err != nil {
return nil, starsErr(err)
}
return emptyStarsStatus(&tg.StarsAmount{Amount: balance.Balance}), nil
}
// onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。
// 末页必须省略 next_offsetflag 不置),否则 DrKLO 会无限翻页。
func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) {

View file

@ -4,8 +4,11 @@ import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/iamxvbaba/td/tg"
@ -40,9 +43,114 @@ func devStarsGiftOptions() []tg.StarsGiftOption {
}
}
type starsGiftPurchaseService interface {
IssueGiftPurchaseForm(context.Context, domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error)
PurchaseGift(context.Context, domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error)
func devStarsGiveawayOptions() []tg.StarsGiveawayOption {
return []tg.StarsGiveawayOption{
{Default: true, Stars: 1000, YearlyBoosts: 4, Currency: "USD", Amount: 99, Winners: []tg.StarsGiveawayWinnersOption{
{Default: true, Users: 1, PerUserStars: 1000}, {Users: 2, PerUserStars: 500},
{Users: 5, PerUserStars: 200}, {Users: 10, PerUserStars: 100},
}},
{Stars: 2500, YearlyBoosts: 10, Currency: "USD", Amount: 199, Winners: []tg.StarsGiveawayWinnersOption{
{Default: true, Users: 1, PerUserStars: 2500}, {Users: 5, PerUserStars: 500}, {Users: 10, PerUserStars: 250},
}},
{Stars: 5000, YearlyBoosts: 20, Currency: "USD", Amount: 399, Winners: []tg.StarsGiveawayWinnersOption{
{Default: true, Users: 1, PerUserStars: 5000}, {Users: 5, PerUserStars: 1000}, {Users: 10, PerUserStars: 500},
}},
}
}
func (r *Router) devStarsFiatPaymentForm(
buyerUserID int64,
form domain.StarsPurchaseForm,
title, description string,
users []domain.User,
) *tg.PaymentsPaymentForm {
return &tg.PaymentsPaymentForm{
FormID: form.FormID,
BotID: domain.OfficialSystemUserID,
Title: title, Description: description,
Invoice: tg.Invoice{
Test: true, Currency: form.Currency,
Prices: []tg.LabeledPrice{{Label: branding.StarsName, Amount: form.Amount}},
},
ProviderID: domain.OfficialSystemUserID,
URL: r.publicLinkQuery("payments/dev-stars", url.Values{
"form_id": []string{strconv.FormatInt(form.FormID, 10)},
}),
Users: tgUsersForViewer(buyerUserID, users),
}
}
func validDevStarsPaymentCredentials(credentials tg.InputPaymentCredentialsClass, formID int64) bool {
value, ok := credentials.(*tg.InputPaymentCredentials)
if !ok || value == nil || value.Save || formID == 0 {
return false
}
var payload map[string]string
if err := json.Unmarshal([]byte(value.Data.Data), &payload); err != nil || len(payload) != 2 {
return false
}
return payload["type"] == "telesrv_dev" && payload["form_id"] == strconv.FormatInt(formID, 10)
}
func (r *Router) onPaymentsGetStarsGiveawayOptions(ctx context.Context) ([]tg.StarsGiveawayOption, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
return devStarsGiveawayOptions(), nil
}
func (r *Router) onPaymentsGetGiveawayInfo(ctx context.Context, req *tg.PaymentsGetGiveawayInfoRequest) (tg.PaymentsGiveawayInfoClass, error) {
if req == nil || req.MsgID <= 0 {
return nil, messageIDInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
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.Stars.(starsGiveawayInfoService)
if !ok {
return nil, notImplementedErr()
}
info, err := service.GetGiveawayInfo(ctx, userID, peer.ID, req.MsgID, int(r.clock.Now().Unix()))
if err != nil {
if errors.Is(err, domain.ErrMessageIDInvalid) {
return nil, messageIDInvalidErr()
}
return nil, starsPurchaseErr(err)
}
out := &tg.PaymentsGiveawayInfo{StartDate: info.StartDate}
if info.Participating {
out.SetParticipating(true)
}
if info.PreparingResults {
out.SetPreparingResults(true)
}
if info.JoinedTooEarlyDate > 0 {
out.SetJoinedTooEarlyDate(info.JoinedTooEarlyDate)
}
if info.AdminDisallowedChatID > 0 {
out.SetAdminDisallowedChatID(info.AdminDisallowedChatID)
}
if info.DisallowedCountry != "" {
out.SetDisallowedCountry(info.DisallowedCountry)
}
return out, nil
}
type starsPurchaseService interface {
IssuePurchaseForm(context.Context, domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error)
Purchase(context.Context, domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error)
}
type starsGiveawayInfoService interface {
GetGiveawayInfo(context.Context, int64, int64, int, int) (domain.StarsGiveawayInfo, error)
}
func userGiftUnavailableErr() error { return tgerr.New(400, "USER_GIFT_UNAVAILABLE") }
@ -106,6 +214,125 @@ func starsGiftPurpose(inv *tg.InputInvoiceStars) (*tg.InputStorePaymentStarsGift
return purpose, ok && purpose != nil
}
func starsGiveawayPurpose(inv *tg.InputInvoiceStars) (*tg.InputStorePaymentStarsGiveaway, bool) {
if inv == nil {
return nil, false
}
purpose, ok := inv.Purpose.(*tg.InputStorePaymentStarsGiveaway)
return purpose, ok && purpose != nil
}
func (r *Router) validateStarsGiveawayPurpose(ctx context.Context, userID int64, purpose *tg.InputStorePaymentStarsGiveaway) (tg.StarsGiveawayOption, *domain.StarsGiveawayPurchase, error) {
if purpose == nil || purpose.Stars <= 0 || purpose.Amount <= 0 || purpose.Currency == "" ||
purpose.RandomID == 0 || purpose.Users <= 0 || purpose.UntilDate <= 0 || purpose.BoostPeer == nil {
return tg.StarsGiveawayOption{}, nil, purposeInvalidErr()
}
var matched tg.StarsGiveawayOption
var perUserStars int64
found := false
for _, option := range devStarsGiveawayOptions() {
if option.Stars != purpose.Stars || option.Currency != purpose.Currency || option.Amount != purpose.Amount {
continue
}
for _, winners := range option.Winners {
if winners.Users == purpose.Users {
matched, perUserStars, found = option, winners.PerUserStars, true
break
}
}
if found {
break
}
}
if !found {
return tg.StarsGiveawayOption{}, nil, starsFormAmountMismatchErr()
}
now := int(r.clock.Now().Unix())
if purpose.UntilDate <= now || purpose.UntilDate > now+7*24*60*60 || len(purpose.AdditionalPeers) > 10 ||
len(purpose.CountriesISO2) > 10 || len([]rune(purpose.PrizeDescription)) > 128 {
return tg.StarsGiveawayOption{}, nil, purposeInvalidErr()
}
boostPeer, err := r.starsGiveawayAdminChannel(ctx, userID, purpose.BoostPeer)
if err != nil {
return tg.StarsGiveawayOption{}, nil, err
}
additional := make([]domain.Peer, 0, len(purpose.AdditionalPeers))
seenChannels := map[int64]struct{}{boostPeer.ID: struct{}{}}
for _, input := range purpose.AdditionalPeers {
peer, err := r.starsGiveawayAdminChannel(ctx, userID, input)
if err != nil {
return tg.StarsGiveawayOption{}, nil, err
}
if _, exists := seenChannels[peer.ID]; exists {
return tg.StarsGiveawayOption{}, nil, purposeInvalidErr()
}
seenChannels[peer.ID] = struct{}{}
additional = append(additional, peer)
}
countries := append([]string(nil), purpose.CountriesISO2...)
seenCountries := make(map[string]struct{}, len(countries))
for _, country := range countries {
if len(country) != 2 || country != strings.ToUpper(country) || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z' {
return tg.StarsGiveawayOption{}, nil, purposeInvalidErr()
}
if _, exists := seenCountries[country]; exists {
return tg.StarsGiveawayOption{}, nil, purposeInvalidErr()
}
seenCountries[country] = struct{}{}
}
return matched, &domain.StarsGiveawayPurchase{
BoostPeer: boostPeer, AdditionalPeers: additional, CountriesISO2: countries,
PrizeDescription: purpose.PrizeDescription, RandomID: purpose.RandomID, UntilDate: purpose.UntilDate,
Users: purpose.Users, PerUserStars: perUserStars, YearlyBoosts: matched.YearlyBoosts,
OnlyNewSubscribers: purpose.OnlyNewSubscribers, WinnersAreVisible: purpose.WinnersAreVisible,
}, nil
}
func (r *Router) starsGiveawayAdminChannel(ctx context.Context, userID int64, input tg.InputPeerClass) (domain.Peer, error) {
if r.deps.Channels == nil {
return domain.Peer{}, notImplementedErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
if err != nil {
return domain.Peer{}, err
}
if peer.Type != domain.PeerTypeChannel || peer.ID <= 0 {
return domain.Peer{}, peerIDInvalidErr()
}
view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID)
if err != nil {
return domain.Peer{}, channelInvalidErr(err)
}
if view.Self.Status != domain.ChannelMemberActive || !channelMemberIsAdmin(view.Self) ||
(view.Channel.Broadcast && !view.Self.CanPostChannelMessages()) {
return domain.Peer{}, tgerr400("CHAT_ADMIN_REQUIRED")
}
return peer, nil
}
func (r *Router) starsGiveawayPaymentForm(ctx context.Context, buyerUserID int64, purpose *tg.InputStorePaymentStarsGiveaway) (tg.PaymentsPaymentFormClass, error) {
_, giveaway, err := r.validateStarsGiveawayPurpose(ctx, buyerUserID, purpose)
if err != nil {
return nil, err
}
service, ok := r.deps.Stars.(starsPurchaseService)
if !ok {
return nil, notImplementedErr()
}
now := int(r.clock.Now().Unix())
form, err := service.IssuePurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGiveaway, BuyerUserID: buyerUserID, Giveaway: giveaway,
Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount,
IssuedAt: now, ExpiresAt: now + 600,
})
if err != nil {
return nil, starsPurchaseErr(err)
}
return r.devStarsFiatPaymentForm(buyerUserID, form,
"Stars giveaway", "Launch a Stars giveaway",
[]domain.User{domain.OfficialSystemUser()}), nil
}
// onPaymentsGetStarGifts 返回可购买礼物目录hash 命中返回 NotModified
func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) {
if r.deps.Gifts == nil {
@ -133,10 +360,12 @@ func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.Payme
// onPaymentsGetPaymentForm 处理 Stars 专用 invoice
// - inputInvoiceStarGift 返回 paymentFormStarGift。
// - inputInvoiceStars(inputStorePaymentStarsTopup) 返回 paymentFormStars。
// - inputInvoiceStars(top-up/gift/giveaway) 返回普通 test paymentForm由本地 dev
// provider WebView 生成 form-bound credentials 后走 sendPaymentForm。
//
// 崩溃约束star gift invoice 必须返 paymentFormStarGift#b425cfe1TDesktop 单分支 match
// Stars 表单 Invoice.Prices 必须非空且 Currency=XTRDrKLO/TDesktop 读 prices.front())。
// Stars 法币购买表单 Invoice.Prices 必须非空且保持套餐 currency/amount不能返回
// paymentFormStars否则 TDesktop 会把它解释为花现有 XTRAndroid 会打开空 URL WebView。
func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsGetPaymentFormRequest) (tg.PaymentsPaymentFormClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
@ -150,6 +379,9 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
if purpose, gift := starsGiftPurpose(inv); gift {
return r.starsGiftPaymentForm(ctx, userID, purpose)
}
if purpose, giveaway := starsGiveawayPurpose(inv); giveaway {
return r.starsGiveawayPaymentForm(ctx, userID, purpose)
}
purpose, ok := starsTopupPurpose(inv)
if !ok {
return nil, notImplementedErr()
@ -160,7 +392,7 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
if _, _, err := r.validateStarsTopupPurpose(ctx, userID, purpose); err != nil {
return nil, err
}
return r.starsTopupPaymentForm(userID, purpose), nil
return r.starsTopupPaymentForm(ctx, userID, purpose)
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
return r.starGiftUpgradePaymentForm(ctx, userID, inv)
@ -228,6 +460,57 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
}, nil
}
// onPaymentsValidateRequestedInfo is the read-only pre-submit gate used by
// TDesktop's ordinary payment-form checkout. Direct Stars purchase invoices
// advertise no requested information or flexible shipping, so a valid result
// deliberately carries neither an ID nor shipping options. Package, recipient
// and giveaway permissions are revalidated without issuing a form or writing
// ledger/message/update state. Other invoice families remain unsupported here
// rather than being accepted as an empty generic bot invoice.
func (r *Router) onPaymentsValidateRequestedInfo(ctx context.Context, req *tg.PaymentsValidateRequestedInfoRequest) (*tg.PaymentsValidatedRequestedInfo, error) {
if req == nil || req.Invoice == nil {
return nil, inputRequestInvalidErr()
}
// The forms returned by devStarsFiatPaymentForm request no personal data.
// Reject even present-but-empty flags so a caller cannot make data appear
// validated when this module neither stores nor forwards it. Save is safe as
// a no-op only while the information object is exactly empty.
if !req.Info.Zero() {
return nil, tgerr.New(400, "REQUESTED_INFO_INVALID")
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
inv, ok := req.Invoice.(*tg.InputInvoiceStars)
if !ok || inv == nil {
return nil, notImplementedErr()
}
if purpose, ok := starsGiftPurpose(inv); ok {
if _, err := validateStarsGiftOption(purpose); err != nil {
return nil, err
}
if _, err := r.starsGiftRecipient(ctx, userID, purpose.UserID); err != nil {
return nil, err
}
return &tg.PaymentsValidatedRequestedInfo{}, nil
}
if purpose, ok := starsGiveawayPurpose(inv); ok {
if _, _, err := r.validateStarsGiveawayPurpose(ctx, userID, purpose); err != nil {
return nil, err
}
return &tg.PaymentsValidatedRequestedInfo{}, nil
}
purpose, ok := starsTopupPurpose(inv)
if !ok {
return nil, notImplementedErr()
}
if _, _, err := r.validateStarsTopupPurpose(ctx, userID, purpose); err != nil {
return nil, err
}
return &tg.PaymentsValidatedRequestedInfo{}, nil
}
func (r *Router) starsGiftPaymentForm(ctx context.Context, buyerUserID int64, purpose *tg.InputStorePaymentStarsGift) (tg.PaymentsPaymentFormClass, error) {
if _, err := validateStarsGiftOption(purpose); err != nil {
return nil, err
@ -236,31 +519,27 @@ func (r *Router) starsGiftPaymentForm(ctx context.Context, buyerUserID int64, pu
if err != nil {
return nil, err
}
service, ok := r.deps.Stars.(starsGiftPurchaseService)
service, ok := r.deps.Stars.(starsPurchaseService)
if !ok {
return nil, notImplementedErr()
}
now := int(r.clock.Now().Unix())
form, err := service.IssueGiftPurchaseForm(ctx, domain.StarsGiftPurchaseForm{
BuyerUserID: buyerUserID, RecipientUserID: recipient.ID,
form, err := service.IssuePurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGift, BuyerUserID: buyerUserID, RecipientUserID: recipient.ID,
Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount,
IssuedAt: now, ExpiresAt: now + 600,
})
if err != nil {
return nil, starsGiftPurchaseErr(err)
return nil, starsPurchaseErr(err)
}
users := []domain.User{domain.OfficialSystemUser(), recipient}
return &tg.PaymentsPaymentFormStars{
FormID: form.FormID, BotID: domain.OfficialSystemUserID,
Title: "Gift Stars", Description: "Gift Stars to a friend",
Invoice: tg.Invoice{Currency: "XTR", Prices: []tg.LabeledPrice{{Label: branding.StarsName, Amount: purpose.Stars}}},
Users: tgUsersForViewer(buyerUserID, users),
}, nil
return r.devStarsFiatPaymentForm(buyerUserID, form,
"Gift Stars", "Gift Stars to a friend", users), nil
}
// onPaymentsSendStarsForm 处理 star gift 与 Stars topup
// - star gift: Debit→投递/记账,失败补偿退款。
// - topup: 校验测试包白名单→Credit 本地账本
// onPaymentsSendStarsForm 只处理真正以 XTR/TON 付款的 Star Gift invoice。
// 直接购买 Stars 是法币 test paymentForm必须携带 dev provider credentials 走
// sendPaymentForm禁止把它伪装为 sendStarsForm 后无凭证铸币
//
// 返回 paymentResult{updates}(含 updateStarsBalance用户礼物还含私聊服务消息
// 崩溃约束:必须返回合法 paymentResult{非空 Updates}DrKLO 强转)。
@ -273,11 +552,8 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
return nil, internalErr()
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
if purpose, gift := starsGiftPurpose(inv); gift {
return r.sendStarsGiftPurchase(ctx, userID, req.FormID, purpose)
}
return r.sendStarsTopupForm(ctx, userID, req.FormID, inv)
if _, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
return nil, tgerr.New(400, "PAYMENT_CREDENTIALS_INVALID")
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
return r.sendStarGiftUpgradeForm(ctx, userID, req.FormID, inv)
@ -378,10 +654,10 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
// onPaymentsSendPaymentForm is the Android invoice-billing completion path.
// The local development provider does not charge an external card, but it
// accepts only a persisted Stars-gift form and runs the same atomic settlement
// as TDesktop's paymentFormStars/sendStarsForm compatibility path.
// onPaymentsSendPaymentForm is the common Android/TDesktop fiat checkout path.
// The local dev provider does not charge an external card; its WebView emits a
// form-bound marker, and the persisted form/purpose remains the authoritative
// package and idempotency boundary.
func (r *Router) onPaymentsSendPaymentForm(ctx context.Context, req *tg.PaymentsSendPaymentFormRequest) (tg.PaymentsPaymentResultClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
@ -394,17 +670,25 @@ func (r *Router) onPaymentsSendPaymentForm(ctx context.Context, req *tg.Payments
if !ok {
return nil, notImplementedErr()
}
purpose, ok := starsGiftPurpose(inv)
if !ok {
return nil, notImplementedErr()
}
if req.Credentials == nil {
if !validDevStarsPaymentCredentials(req.Credentials, req.FormID) {
return nil, tgerr.New(400, "PAYMENT_CREDENTIALS_INVALID")
}
if req.TipAmount != 0 {
if _, present := req.GetRequestedInfoID(); present || req.RequestedInfoID != "" {
return nil, tgerr.New(400, "REQUESTED_INFO_ID_INVALID")
}
if _, present := req.GetShippingOptionID(); present || req.ShippingOptionID != "" {
return nil, tgerr.New(400, "SHIPPING_OPTION_INVALID")
}
if _, present := req.GetTipAmount(); present || req.TipAmount != 0 {
return nil, tgerr.New(400, "TIP_AMOUNT_INVALID")
}
return r.sendStarsGiftPurchase(ctx, userID, req.FormID, purpose)
if purpose, ok := starsGiftPurpose(inv); ok {
return r.sendStarsGiftPurchase(ctx, userID, req.FormID, purpose)
}
if purpose, ok := starsGiveawayPurpose(inv); ok {
return r.sendStarsGiveawayPurchase(ctx, userID, req.FormID, purpose)
}
return r.sendStarsTopupForm(ctx, userID, req.FormID, inv)
}
func (r *Router) sendStarsGiftPurchase(ctx context.Context, buyerUserID, formID int64, purpose *tg.InputStorePaymentStarsGift) (tg.PaymentsPaymentResultClass, error) {
@ -418,30 +702,69 @@ func (r *Router) sendStarsGiftPurchase(ctx context.Context, buyerUserID, formID
if err != nil {
return nil, err
}
service, ok := r.deps.Stars.(starsGiftPurchaseService)
service, ok := r.deps.Stars.(starsPurchaseService)
if !ok {
return nil, notImplementedErr()
}
result, err := service.PurchaseGift(ctx, domain.StarsGiftPurchaseRequest{
StarsGiftPurchaseForm: domain.StarsGiftPurchaseForm{
FormID: formID, BuyerUserID: buyerUserID, RecipientUserID: recipient.ID,
result, err := service.Purchase(ctx, domain.StarsPurchaseRequest{
StarsPurchaseForm: domain.StarsPurchaseForm{
FormID: formID, Kind: domain.StarsPurchaseGift, BuyerUserID: buyerUserID, RecipientUserID: recipient.ID,
Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount,
},
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx),
})
if err != nil {
return nil, starsGiftPurchaseErr(err)
return nil, starsPurchaseErr(err)
}
updates := r.starGiftSendUpdates(ctx, buyerUserID, result.Send)
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
func starsGiftPurchaseErr(err error) error {
func (r *Router) sendStarsGiveawayPurchase(ctx context.Context, buyerUserID, formID int64, purpose *tg.InputStorePaymentStarsGiveaway) (tg.PaymentsPaymentResultClass, error) {
if formID == 0 {
return nil, formIDEmptyErr()
}
_, giveaway, err := r.validateStarsGiveawayPurpose(ctx, buyerUserID, purpose)
if err != nil {
return nil, err
}
service, ok := r.deps.Stars.(starsPurchaseService)
if !ok {
return nil, notImplementedErr()
}
result, err := service.Purchase(ctx, domain.StarsPurchaseRequest{
StarsPurchaseForm: domain.StarsPurchaseForm{
FormID: formID, Kind: domain.StarsPurchaseGiveaway, BuyerUserID: buyerUserID, Giveaway: giveaway,
Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount,
},
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx),
})
if err != nil {
if errors.Is(err, domain.ErrChannelInvalid) || errors.Is(err, domain.ErrChannelPrivate) ||
errors.Is(err, domain.ErrChannelWriteForbidden) || errors.Is(err, domain.ErrChannelAdminRequired) ||
errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
return nil, channelInvalidErr(err)
}
return nil, starsPurchaseErr(err)
}
updates := r.channelMessageUpdatesWithPeerCache(ctx, buyerUserID, result.ChannelSend, 0, newViewerPeerCache(r))
if updates == nil {
updates = &tg.Updates{Date: int(r.clock.Now().Unix())}
}
if !result.Duplicate {
r.enqueueChannelMessageFanout(ctx, buyerUserID, result.ChannelSend, nil)
r.pushChannelDiscussionUpdate(ctx, buyerUserID, result.ChannelSend.Discussion)
}
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
func starsPurchaseErr(err error) error {
switch {
case errors.Is(err, domain.ErrStarsGiftFormExpired):
case errors.Is(err, domain.ErrStarsPurchaseFormExpired):
return tgerr.New(400, "FORM_EXPIRED")
case errors.Is(err, domain.ErrStarsGiftFormInvalid):
case errors.Is(err, domain.ErrStarsPurchaseFormInvalid):
return starsFormAmountMismatchErr()
case errors.Is(err, domain.ErrStarsGiftUnavailable):
return userGiftUnavailableErr()
@ -512,18 +835,28 @@ func (r *Router) validateStarsTopupPurpose(ctx context.Context, userID int64, pu
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: branding.StarsName,
Description: "telesrv dev Stars top-up",
Invoice: tg.Invoice{
Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: branding.StarsName, Amount: purpose.Stars}},
},
Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()}),
func (r *Router) starsTopupPaymentForm(ctx context.Context, userID int64, purpose *tg.InputStorePaymentStarsTopup) (tg.PaymentsPaymentFormClass, error) {
service, ok := r.deps.Stars.(starsPurchaseService)
if !ok {
return nil, notImplementedErr()
}
_, peer, err := r.validateStarsTopupPurpose(ctx, userID, purpose)
if err != nil {
return nil, err
}
now := int(r.clock.Now().Unix())
form, err := service.IssuePurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseTopup, BuyerUserID: userID,
SpendPurposePeer: peer,
Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount,
IssuedAt: now, ExpiresAt: now + 600,
})
if err != nil {
return nil, starsPurchaseErr(err)
}
return r.devStarsFiatPaymentForm(userID, form,
branding.StarsName, "telesrv dev Stars top-up",
[]domain.User{domain.OfficialSystemUser()}), nil
}
func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStars) (tg.PaymentsPaymentResultClass, error) {
@ -534,24 +867,27 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i
if formID == 0 {
return nil, formIDEmptyErr()
}
if r.deps.Stars == nil {
service, ok := r.deps.Stars.(starsPurchaseService)
if !ok {
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")
result, err := service.Purchase(ctx, domain.StarsPurchaseRequest{
StarsPurchaseForm: domain.StarsPurchaseForm{
FormID: formID, Kind: domain.StarsPurchaseTopup, BuyerUserID: userID,
SpendPurposePeer: peer,
Stars: purpose.Stars, Currency: purpose.Currency, Amount: purpose.Amount,
},
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx),
})
if err != nil {
return nil, starsErr(err)
return nil, starsPurchaseErr(err)
}
return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(balance.Balance, r.clock.Now().Unix())}, nil
return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(result.Balance.Balance, r.clock.Now().Unix())}, nil
}
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SavedStarGiftRef, *tg.Updates, error) {
@ -1419,20 +1755,6 @@ func savedStarGiftUserIDs(gifts []domain.SavedStarGift) []int64 {
return ids
}
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}}},

View file

@ -3,7 +3,9 @@ package rpc
import (
"context"
"fmt"
"reflect"
"testing"
"time"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
@ -22,6 +24,83 @@ import (
"telesrv/internal/store/memory"
)
type starsTopupRPCStore struct {
*memory.StarsStore
nextFormID int64
forms map[int64]domain.StarsPurchaseForm
settled map[int64]domain.StarsPurchaseResult
channel domain.Channel
}
func devStarsCredentials(formID int64) *tg.InputPaymentCredentials {
return &tg.InputPaymentCredentials{Data: tg.DataJSON{Data: fmt.Sprintf(`{"type":"telesrv_dev","form_id":"%d"}`, formID)}}
}
func newStarsTopupRPCStore() *starsTopupRPCStore {
return &starsTopupRPCStore{
StarsStore: memory.NewStarsStore(), nextFormID: 91000,
forms: make(map[int64]domain.StarsPurchaseForm), settled: make(map[int64]domain.StarsPurchaseResult),
}
}
func (s *starsTopupRPCStore) IssueStarsPurchaseForm(_ context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
s.nextFormID++
form.FormID = s.nextFormID
s.forms[form.FormID] = form
return form, nil
}
func (s *starsTopupRPCStore) PurchaseStars(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
form, ok := s.forms[req.FormID]
if !ok || form.Kind != req.Kind || form.BuyerUserID != req.BuyerUserID ||
form.RecipientUserID != req.RecipientUserID || form.SpendPurposePeer != req.SpendPurposePeer ||
form.Stars != req.Stars || form.Currency != req.Currency || form.Amount != req.Amount ||
!reflect.DeepEqual(form.Giveaway, req.Giveaway) {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
if result, ok := s.settled[req.FormID]; ok {
result.Duplicate = true
return result, nil
}
if req.Kind == domain.StarsPurchaseGiveaway {
g := req.Giveaway
result := domain.StarsPurchaseResult{TransactionID: fmt.Sprintf("stars-giveaway-test:%d", req.FormID)}
channel := s.channel
if channel.ID == 0 {
channel = domain.Channel{ID: g.BoostPeer.ID, Megagroup: true}
}
result.ChannelSend = domain.SendChannelMessageResult{
Channel: channel,
Message: domain.ChannelMessage{ChannelID: g.BoostPeer.ID, ID: 71, RandomID: g.RandomID, SenderUserID: req.BuyerUserID,
Date: req.Date, Pts: 9, Media: &domain.MessageMedia{Kind: domain.MessageMediaKindGiveaway, Giveaway: &domain.MessageGiveaway{
Channels: []int64{g.BoostPeer.ID}, Quantity: g.Users, Stars: req.Stars, UntilDate: g.UntilDate,
}}},
Event: domain.ChannelUpdateEvent{ChannelID: g.BoostPeer.ID, Type: domain.ChannelUpdateNewMessage, Pts: 9, PtsCount: 1, Date: req.Date},
}
result.ChannelSend.Event.Message = result.ChannelSend.Message
s.settled[req.FormID] = result
return result, nil
}
if req.Kind != domain.StarsPurchaseTopup {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
balance, err := s.StarsStore.Credit(ctx, req.BuyerUserID, req.Stars, domain.StarsReasonTopup,
req.SpendPurposePeer, req.Date, "Stars top-up", "test purchase")
if err != nil {
return domain.StarsPurchaseResult{}, err
}
result := domain.StarsPurchaseResult{Balance: balance, TransactionID: fmt.Sprintf("stars-topup-test:%d", req.FormID)}
s.settled[req.FormID] = result
return result, nil
}
func (s *starsTopupRPCStore) GetStarsGiveawayInfo(_ context.Context, viewerUserID, channelID int64, messageID, _ int) (domain.StarsGiveawayInfo, error) {
if viewerUserID <= 0 || channelID != s.channel.ID || messageID != 71 {
return domain.StarsGiveawayInfo{}, domain.ErrMessageIDInvalid
}
return domain.StarsGiveawayInfo{StartDate: 1_700_000_200, Participating: true}, nil
}
func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain.StarGift) {
return starGiftTestRouterWithPremium(t, false)
}
@ -48,11 +127,12 @@ func starGiftTestRouterWithPremium(t *testing.T, requirePremium bool) (*Router,
giftStore := memory.NewStarGiftStore()
giftStore.SeedCatalog([]domain.StarGift{gift})
gifts := appstargifts.NewService(giftStore, nil, 2)
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
starsStore := newStarsTopupRPCStore()
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398, PublicBaseURL: "https://links.example.test"}, Deps{
Users: appusers.NewService(users),
Messages: appmessages.NewService(msgStore, dialogs),
Channels: appchannels.NewService(channelStore),
Stars: appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(1000)),
Stars: appstars.NewService(starsStore, appstars.WithStartingGrant(1000), appstars.WithPurchaseStore(starsStore)),
Gifts: gifts,
}, zaptest.NewLogger(t), clock.System)
return r, sender, recipient, gift
@ -85,6 +165,115 @@ func TestStarGiftPurchaseRequiresActivePremium(t *testing.T) {
}
}
func TestStarsGiveawayCatalogFormSettlementReplayAndInfo(t *testing.T) {
ctx := context.Background()
now := 1_700_000_100
users := memory.NewUserStore()
owner, err := users.Create(ctx, domain.User{AccessHash: 7201, Phone: "15550007201", FirstName: "GiveawayOwner"})
if err != nil {
t.Fatal(err)
}
member, err := users.Create(ctx, domain.User{AccessHash: 7202, Phone: "15550007202", FirstName: "GiveawayMember"})
if err != nil {
t.Fatal(err)
}
channelStore := memory.NewChannelStore()
created, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Giveaway Group", Megagroup: true,
MemberUserIDs: []int64{member.ID}, Date: now - 10,
})
if err != nil {
t.Fatal(err)
}
starsStore := newStarsTopupRPCStore()
starsStore.channel = created.Channel
r := New(Config{DC: 2, PublicBaseURL: "https://links.example.test"}, Deps{
Users: appusers.NewService(users), Channels: appchannels.NewService(channelStore),
Stars: appstars.NewService(starsStore, appstars.WithStartingGrant(0), appstars.WithPurchaseStore(starsStore)),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(int64(now), 0)})
ownerCtx := WithUserID(ctx, owner.ID)
options, err := r.onPaymentsGetStarsGiveawayOptions(ownerCtx)
if err != nil || len(options) != 3 || len(options[0].Winners) < 2 || options[0].StoreProduct != "" {
t.Fatalf("giveaway options=%+v err=%v", options, err)
}
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
purpose := &tg.InputStorePaymentStarsGiveaway{
WinnersAreVisible: true, Stars: options[0].Stars, BoostPeer: peer,
RandomID: 7201001, UntilDate: now + 3600, Currency: options[0].Currency,
Amount: options[0].Amount, Users: options[0].Winners[1].Users,
}
invoice := &tg.InputInvoiceStars{Purpose: purpose}
validated, err := r.onPaymentsValidateRequestedInfo(ownerCtx, &tg.PaymentsValidateRequestedInfoRequest{Invoice: invoice})
if err != nil || validated == nil || !validated.Zero() {
t.Fatalf("validate giveaway requested info=%+v err=%v", validated, err)
}
if len(starsStore.forms) != 0 || len(starsStore.settled) != 0 {
t.Fatalf("giveaway validation mutated store: forms=%d settled=%d", len(starsStore.forms), len(starsStore.settled))
}
formClass, err := r.onPaymentsGetPaymentForm(ownerCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
if err != nil {
t.Fatalf("get giveaway payment form: %v", err)
}
form, ok := formClass.(*tg.PaymentsPaymentForm)
if !ok || form.FormID == 0 || !form.Invoice.Test || form.Invoice.Currency != purpose.Currency ||
len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != purpose.Amount {
t.Fatalf("giveaway payment form=%T %+v", formClass, formClass)
}
if _, err := r.onPaymentsSendStarsForm(ownerCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: invoice}); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") {
t.Fatalf("sendStarsForm fiat giveaway err=%v", err)
}
resultClass, err := r.onPaymentsSendPaymentForm(ownerCtx, &tg.PaymentsSendPaymentFormRequest{
FormID: form.FormID, Invoice: invoice, Credentials: devStarsCredentials(form.FormID),
})
if err != nil {
t.Fatalf("send giveaway form: %v", err)
}
result, ok := resultClass.(*tg.PaymentsPaymentResult)
if !ok {
t.Fatalf("giveaway result=%T", resultClass)
}
updates, ok := result.Updates.(*tg.Updates)
if !ok || len(updates.Updates) == 0 {
t.Fatalf("giveaway updates=%T %+v", result.Updates, result.Updates)
}
launchID := 0
for _, update := range updates.Updates {
newChannel, ok := update.(*tg.UpdateNewChannelMessage)
if !ok || newChannel.PtsCount != 1 {
continue
}
message, ok := newChannel.Message.(*tg.Message)
if !ok {
t.Fatalf("giveaway launch message=%T", newChannel.Message)
}
media, ok := message.Media.(*tg.MessageMediaGiveaway)
if !ok || media.Stars != purpose.Stars || media.Quantity != purpose.Users || media.UntilDate != purpose.UntilDate {
t.Fatalf("giveaway launch media=%T %+v", message.Media, message.Media)
}
launchID = message.ID
}
if launchID == 0 {
t.Fatalf("giveaway updates missing updateNewChannelMessage: %+v", updates.Updates)
}
if _, err := r.onPaymentsSendPaymentForm(ownerCtx, &tg.PaymentsSendPaymentFormRequest{
FormID: form.FormID, Invoice: invoice, Credentials: devStarsCredentials(form.FormID),
}); err != nil {
t.Fatalf("Android sendPaymentForm replay: %v", err)
}
infoClass, err := r.onPaymentsGetGiveawayInfo(ownerCtx, &tg.PaymentsGetGiveawayInfoRequest{Peer: peer, MsgID: launchID})
info, ok := infoClass.(*tg.PaymentsGiveawayInfo)
if err != nil || !ok || !info.Participating || info.StartDate == 0 {
t.Fatalf("get giveaway info=%T %+v err=%v", infoClass, infoClass, err)
}
memberPurpose := *purpose
memberPurpose.RandomID++
if _, err := r.onPaymentsGetPaymentForm(WithUserID(ctx, member.ID), &tg.PaymentsGetPaymentFormRequest{
Invoice: &tg.InputInvoiceStars{Purpose: &memberPurpose},
}); !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") {
t.Fatalf("member giveaway form err=%v, want CHAT_ADMIN_REQUIRED", err)
}
}
type uniqueGiftRPCService struct {
GiftsService
unique domain.UniqueStarGift
@ -1456,7 +1645,7 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
Sticker: domain.Document{ID: 701, AccessHash: 7, DCID: 2, MimeType: "application/x-tgsticker", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}}
giftStore := memory.NewStarGiftStore()
giftStore.SeedCatalog([]domain.StarGift{gift})
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398, PublicBaseURL: "https://links.example.test"}, Deps{
Users: appusers.NewService(users),
Messages: appmessages.NewService(msgStore, dialogs),
Stars: appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(1000)), // < 5000
@ -1550,30 +1739,63 @@ func TestStarsTopupInvoiceFallbackCreditsBalance(t *testing.T) {
if err != nil {
t.Fatalf("getPaymentForm topup: %v", err)
}
form, ok := formRes.(*tg.PaymentsPaymentFormStars)
form, ok := formRes.(*tg.PaymentsPaymentForm)
if !ok {
t.Fatalf("form = %T, want *tg.PaymentsPaymentFormStars", formRes)
t.Fatalf("form = %T, want *tg.PaymentsPaymentForm", 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.FormID == 0 {
t.Fatal("form id = 0, want persisted random checkout id")
}
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 !form.Invoice.Test || form.Invoice.Currency != opt.Currency || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != opt.Amount {
t.Fatalf("form invoice = %+v, want test %s + 1 price %d", form.Invoice, opt.Currency, opt.Amount)
}
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 _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") {
t.Fatalf("sendStarsForm fiat topup err = %v, want PAYMENT_CREDENTIALS_INVALID", err)
}
if _, err := r.onPaymentsSendPaymentForm(senderCtx, &tg.PaymentsSendPaymentFormRequest{
FormID: form.FormID + 1, Invoice: inv, Credentials: devStarsCredentials(form.FormID + 1),
}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
t.Fatalf("sendPaymentForm 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)
}
for name, request := range map[string]*tg.PaymentsSendPaymentFormRequest{
"missing": {FormID: form.FormID, Invoice: inv},
"wrong form marker": {FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID + 1)},
"saved credentials": {FormID: form.FormID, Invoice: inv, Credentials: &tg.InputPaymentCredentials{
Save: true, Data: devStarsCredentials(form.FormID).Data,
}},
} {
if _, err := r.onPaymentsSendPaymentForm(senderCtx, request); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") {
t.Fatalf("%s credentials err = %v, want PAYMENT_CREDENTIALS_INVALID", name, err)
}
}
withInfo := &tg.PaymentsSendPaymentFormRequest{FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID)}
withInfo.SetRequestedInfoID("unexpected")
if _, err := r.onPaymentsSendPaymentForm(senderCtx, withInfo); !tgerr.Is(err, "REQUESTED_INFO_ID_INVALID") {
t.Fatalf("requested info err = %v", err)
}
withShipping := &tg.PaymentsSendPaymentFormRequest{FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID)}
withShipping.SetShippingOptionID("unexpected")
if _, err := r.onPaymentsSendPaymentForm(senderCtx, withShipping); !tgerr.Is(err, "SHIPPING_OPTION_INVALID") {
t.Fatalf("shipping option err = %v", err)
}
withTip := &tg.PaymentsSendPaymentFormRequest{FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID)}
withTip.SetTipAmount(1)
if _, err := r.onPaymentsSendPaymentForm(senderCtx, withTip); !tgerr.Is(err, "TIP_AMOUNT_INVALID") {
t.Fatalf("tip err = %v", err)
}
payRes, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv})
payRes, err := r.onPaymentsSendPaymentForm(senderCtx, &tg.PaymentsSendPaymentFormRequest{
FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID),
})
if err != nil {
t.Fatalf("sendStarsForm topup: %v", err)
t.Fatalf("sendPaymentForm topup: %v", err)
}
pay, ok := payRes.(*tg.PaymentsPaymentResult)
if !ok {
@ -1598,6 +1820,14 @@ func TestStarsTopupInvoiceFallbackCreditsBalance(t *testing.T) {
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 3500 {
t.Fatalf("balance after topup = %d, want 3500", bal.Balance)
}
if _, err := r.onPaymentsSendPaymentForm(senderCtx, &tg.PaymentsSendPaymentFormRequest{
FormID: form.FormID, Invoice: inv, Credentials: devStarsCredentials(form.FormID),
}); err != nil {
t.Fatalf("sendPaymentForm exact replay: %v", err)
}
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 3500 {
t.Fatalf("balance after exact replay = %d, want 3500", bal.Balance)
}
page, err := r.deps.Stars.ListTransactions(ctx, sender.ID, domain.StarsTransactionQuery{Limit: 10})
if err != nil {
t.Fatalf("list transactions: %v", err)

View file

@ -17,18 +17,18 @@ import (
type starsFriendGiftRPCStore struct {
*memory.StarsStore
issued domain.StarsGiftPurchaseForm
purchased domain.StarsGiftPurchaseRequest
issued domain.StarsPurchaseForm
purchased domain.StarsPurchaseRequest
purchases int
}
func (s *starsFriendGiftRPCStore) IssueStarsGiftPurchaseForm(_ context.Context, form domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) {
func (s *starsFriendGiftRPCStore) IssueStarsPurchaseForm(_ context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
form.FormID = 70001
s.issued = form
return form, nil
}
func (s *starsFriendGiftRPCStore) PurchaseStarsGift(_ context.Context, req domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) {
func (s *starsFriendGiftRPCStore) PurchaseStars(_ context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
s.purchased = req
s.purchases++
action := &domain.MessageServiceAction{Kind: domain.MessageServiceActionGiftStars, GiftStars: &domain.MessageGiftStarsAction{
@ -41,9 +41,9 @@ func (s *starsFriendGiftRPCStore) PurchaseStarsGift(_ context.Context, req domai
recipient := sender
recipient.ID, recipient.OwnerUserID, recipient.Peer, recipient.Out = 12, req.RecipientUserID,
domain.Peer{Type: domain.PeerTypeUser, ID: req.BuyerUserID}, false
return domain.StarsGiftPurchaseResult{
RecipientBalance: domain.StarsBalance{UserID: req.RecipientUserID, Balance: 4321},
TransactionID: "stars-gift-test",
return domain.StarsPurchaseResult{
Balance: domain.StarsBalance{UserID: req.RecipientUserID, Balance: 4321},
TransactionID: "stars-gift-test",
Send: domain.SendPrivateTextResult{
SenderMessage: sender, RecipientMessage: recipient,
SenderEvent: domain.UpdateEvent{UserID: req.BuyerUserID, Type: domain.UpdateEventNewMessage, Pts: 5, PtsCount: 1, Date: req.Date, Message: sender},
@ -65,14 +65,14 @@ func starsFriendGiftTestRouter(t *testing.T) (*Router, *starsFriendGiftRPCStore,
t.Fatal(err)
}
st := &starsFriendGiftRPCStore{StarsStore: memory.NewStarsStore()}
r := New(Config{DC: 2}, Deps{
r := New(Config{DC: 2, PublicBaseURL: "https://links.example.test"}, Deps{
Users: appusers.NewService(users),
Stars: appstars.NewService(st, appstars.WithStartingGrant(0), appstars.WithGiftPurchaseStore(st)),
Stars: appstars.NewService(st, appstars.WithStartingGrant(0), appstars.WithPurchaseStore(st)),
}, zaptest.NewLogger(t), clock.System)
return r, st, buyer, recipient
}
func TestStarsFriendGiftOptionsFormAndBothSettlementMethods(t *testing.T) {
func TestStarsFriendGiftOptionsFormAndFiatSettlement(t *testing.T) {
r, st, buyer, recipient := starsFriendGiftTestRouter(t)
ctx := WithUserID(context.Background(), buyer.ID)
@ -94,17 +94,24 @@ func TestStarsFriendGiftOptionsFormAndBothSettlementMethods(t *testing.T) {
if err != nil {
t.Fatalf("get gift payment form: %v", err)
}
form, ok := formClass.(*tg.PaymentsPaymentFormStars)
if !ok || form.FormID != 70001 || form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != 2500 {
form, ok := formClass.(*tg.PaymentsPaymentForm)
if !ok || form.FormID != 70001 || !form.Invoice.Test || form.Invoice.Currency != "USD" ||
len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != 199 ||
form.ProviderID != domain.OfficialSystemUserID || form.URL != "https://links.example.test/payments/dev-stars?form_id=70001" {
t.Fatalf("gift payment form = %T %+v", formClass, formClass)
}
if st.issued.BuyerUserID != buyer.ID || st.issued.RecipientUserID != recipient.ID || st.issued.Stars != 2500 || st.issued.ExpiresAt != st.issued.IssuedAt+600 {
if st.issued.Kind != domain.StarsPurchaseGift || st.issued.BuyerUserID != buyer.ID || st.issued.RecipientUserID != recipient.ID || st.issued.Stars != 2500 || st.issued.ExpiresAt != st.issued.IssuedAt+600 {
t.Fatalf("issued form = %+v", st.issued)
}
resultClass, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: invoice})
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: invoice}); !tgerr.Is(err, "PAYMENT_CREDENTIALS_INVALID") {
t.Fatalf("sendStarsForm fiat gift err = %v, want PAYMENT_CREDENTIALS_INVALID", err)
}
resultClass, err := r.onPaymentsSendPaymentForm(ctx, &tg.PaymentsSendPaymentFormRequest{
FormID: form.FormID, Invoice: invoice, Credentials: devStarsCredentials(form.FormID),
})
if err != nil {
t.Fatalf("sendStarsForm gift: %v", err)
t.Fatalf("sendPaymentForm gift: %v", err)
}
result, ok := resultClass.(*tg.PaymentsPaymentResult)
if !ok {
@ -130,14 +137,54 @@ func TestStarsFriendGiftOptionsFormAndBothSettlementMethods(t *testing.T) {
t.Fatalf("purchase request = %+v count=%d", st.purchased, st.purchases)
}
credentials := &tg.InputPaymentCredentials{Data: tg.DataJSON{Data: "{}"}}
if _, err := r.onPaymentsSendPaymentForm(ctx, &tg.PaymentsSendPaymentFormRequest{
FormID: form.FormID, Invoice: invoice, Credentials: credentials,
}); err != nil {
t.Fatalf("sendPaymentForm gift: %v", err)
if st.purchases != 1 {
t.Fatalf("settlement count = %d, want one fiat submit", st.purchases)
}
if st.purchases != 2 {
t.Fatalf("settlement method count = %d, want 2 fake invocations", st.purchases)
}
func TestStarsDirectPurchaseValidateRequestedInfoIsReadOnly(t *testing.T) {
r, st, buyer, recipient := starsFriendGiftTestRouter(t)
ctx := WithUserID(context.Background(), buyer.ID)
topup := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{
Stars: 1000, Currency: "USD", Amount: 99,
}}
gift := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsGift{
UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
Stars: 2500, Currency: "USD", Amount: 199,
}}
for name, invoice := range map[string]tg.InputInvoiceClass{"topup": topup, "gift": gift} {
result, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{
Save: true, Invoice: invoice,
})
if err != nil {
t.Fatalf("%s validateRequestedInfo: %v", name, err)
}
if result == nil || !result.Zero() {
t.Fatalf("%s validated info = %+v, want flags=0", name, result)
}
}
if st.issued.FormID != 0 || st.purchases != 0 {
t.Fatalf("validation mutated purchase store: issued=%+v purchases=%d", st.issued, st.purchases)
}
withInfo := &tg.PaymentsValidateRequestedInfoRequest{Invoice: topup}
withInfo.Info.SetName("unexpected")
if _, err := r.onPaymentsValidateRequestedInfo(ctx, withInfo); !tgerr.Is(err, "REQUESTED_INFO_INVALID") {
t.Fatalf("non-empty info err=%v, want REQUESTED_INFO_INVALID", err)
}
if _, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{
Invoice: &tg.InputInvoiceSlug{Slug: "unsupported"},
}); !tgerr.Is(err, "NOT_IMPLEMENTED") {
t.Fatalf("non-Stars invoice err=%v, want NOT_IMPLEMENTED", err)
}
if _, err := r.onPaymentsValidateRequestedInfo(ctx, &tg.PaymentsValidateRequestedInfoRequest{
Invoice: &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{Stars: 1000, Currency: "USD", Amount: 100}},
}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
t.Fatalf("tampered package err=%v, want STARS_FORM_AMOUNT_MISMATCH", err)
}
if st.issued.FormID != 0 || st.purchases != 0 {
t.Fatalf("invalid validation mutated purchase store: issued=%+v purchases=%d", st.issued, st.purchases)
}
}
@ -157,7 +204,9 @@ func TestStarsFriendGiftRejectsInvalidRecipientAndPackageBeforeStore(t *testing.
if _, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: bad}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
t.Fatalf("tampered package form err = %v", err)
}
if _, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: 70001, Invoice: bad}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
if _, err := r.onPaymentsSendPaymentForm(ctx, &tg.PaymentsSendPaymentFormRequest{
FormID: 70001, Invoice: bad, Credentials: devStarsCredentials(70001),
}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
t.Fatalf("tampered package settle err = %v", err)
}
if st.issued.FormID != 0 || st.purchases != 0 {
@ -165,6 +214,27 @@ func TestStarsFriendGiftRejectsInvalidRecipientAndPackageBeforeStore(t *testing.
}
}
func TestAndroidStorePurchaseFailsClosedInFavorOfInvoiceCheckout(t *testing.T) {
r, _, buyer, recipient := starsFriendGiftTestRouter(t)
ctx := WithUserID(context.Background(), buyer.ID)
purpose := &tg.InputStorePaymentStarsGift{
UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
Stars: 1000, Currency: "USD", Amount: 99,
}
allowed, err := r.onPaymentsCanPurchaseStore(ctx, &tg.PaymentsCanPurchaseStoreRequest{Purpose: purpose})
if err != nil {
t.Fatalf("canPurchaseStore: %v", err)
}
if allowed {
t.Fatal("canPurchaseStore = true, want false")
}
if _, err := r.onPaymentsAssignPlayMarketTransaction(ctx, &tg.PaymentsAssignPlayMarketTransactionRequest{
Receipt: tg.DataJSON{Data: `{"orderId":"unverified"}`}, Purpose: purpose,
}); !tgerr.Is(err, "STORE_PAYMENT_UNAVAILABLE") {
t.Fatalf("assignPlayMarketTransaction err = %v, want STORE_PAYMENT_UNAVAILABLE", err)
}
}
func TestGiftStarsRecipientProjectionCarriesBalanceOnlineAndDifference(t *testing.T) {
action := &domain.MessageServiceAction{Kind: domain.MessageServiceActionGiftStars, GiftStars: &domain.MessageGiftStarsAction{
Currency: "USD", Amount: 99, Stars: 1000, TransactionID: "txn-1", BalanceAfter: 3100,

View file

@ -42,6 +42,27 @@ func TestOnPaymentsGetStarsStatusGranted(t *testing.T) {
}
}
func TestOnPaymentsGetStarsSubscriptionsReturnsTerminalEmptyPage(t *testing.T) {
r := starsRouter(t, 1000)
ctx := WithUserID(context.Background(), 1000000001)
status, err := r.onPaymentsGetStarsSubscriptions(ctx, &tg.PaymentsGetStarsSubscriptionsRequest{
Peer: &tg.InputPeerSelf{}, Offset: "",
})
if err != nil {
t.Fatalf("getStarsSubscriptions: %v", err)
}
amount, ok := status.Balance.(*tg.StarsAmount)
if !ok || amount.Amount != 1000 {
t.Fatalf("balance = %#v, want StarsAmount 1000", status.Balance)
}
if subscriptions, ok := status.GetSubscriptions(); ok || len(subscriptions) != 0 {
t.Fatalf("subscriptions = %+v ok=%v, want absent terminal page", subscriptions, ok)
}
if _, ok := status.GetSubscriptionsNextOffset(); ok {
t.Fatal("empty subscription page unexpectedly has next offset")
}
}
// TON 余额未建模:返回 starsTonAmount 的合法响应(不崩客户端)。
func TestOnPaymentsGetStarsStatusTon(t *testing.T) {
r := starsRouter(t, 1000)

View file

@ -1283,8 +1283,12 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
{name: "account.resetPassword", req: &tg.AccountResetPasswordRequest{}},
{name: "account.updateStatus", req: &tg.AccountUpdateStatusRequest{Offline: true}},
{name: "account.updateDeviceLocked", req: &tg.AccountUpdateDeviceLockedRequest{Period: 60}},
{name: "payments.canPurchaseStore", req: &tg.PaymentsCanPurchaseStoreRequest{Purpose: &tg.InputStorePaymentStarsTopup{Stars: 1000, Currency: "USD", Amount: 99}}},
{name: "payments.getStarsTopupOptions", req: &tg.PaymentsGetStarsTopupOptionsRequest{}},
{name: "payments.getStarsGiftOptions", req: &tg.PaymentsGetStarsGiftOptionsRequest{}},
{name: "payments.getStarsGiveawayOptions", req: &tg.PaymentsGetStarsGiveawayOptionsRequest{}},
{name: "payments.getStarsStatus", req: &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}},
{name: "payments.getStarsSubscriptions", req: &tg.PaymentsGetStarsSubscriptionsRequest{Peer: &tg.InputPeerSelf{}}},
{name: "updates.getDifference", req: &tg.UpdatesGetDifferenceRequest{}},
{name: "users.getFullUser", req: &tg.UsersGetFullUserRequest{ID: &tg.InputUserSelf{}}},
{name: "users.getRequirementsToContact", req: &tg.UsersGetRequirementsToContactRequest{ID: []tg.InputUserClass{&tg.InputUserSelf{}}}},

View file

@ -234,6 +234,13 @@ func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs,
if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 {
userIDs[msg.Media.Contact.UserID] = struct{}{}
}
if msg.Media != nil && msg.Media.Giveaway != nil {
for _, id := range msg.Media.Giveaway.Channels {
if id != 0 && id != currentChannelID {
channelIDs[id] = struct{}{}
}
}
}
collectServiceActionPeerRefs(msg.Media, currentChannelID, userIDs, channelIDs)
collectPollMediaUserRefs(msg.Media, userIDs)
collectTodoMediaUserRefs(msg.Media, userIDs)