feat: sync implement Stars friend gift packages

This commit is contained in:
iamxvbaba 2026-08-02 01:51:02 +08:00
parent 3a123f38db
commit c854fc7b94
15 changed files with 914 additions and 1 deletions

View file

@ -244,6 +244,21 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
}
case domain.MessageServiceActionStarGift:
return tgMessageActionStarGiftForViewer(m.ServiceAction.StarGift, msg.OwnerUserID)
case domain.MessageServiceActionGiftStars:
action := m.ServiceAction.GiftStars
if action == nil || action.Currency == "" || action.Amount <= 0 || action.Stars <= 0 {
return &tg.MessageActionEmpty{}
}
out := &tg.MessageActionGiftStars{
Currency: action.Currency,
Amount: action.Amount,
Stars: action.Stars,
}
// Telegram only exposes the provider transaction id to the receiver.
if !msg.Out && action.TransactionID != "" {
out.SetTransactionID(action.TransactionID)
}
return out
case domain.MessageServiceActionStarGiftUnique:
return tgMessageActionStarGiftUnique(m.ServiceAction.StarGiftUnique)
case domain.MessageServiceActionStarGiftOffer:

View file

@ -27,6 +27,9 @@ func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.Up
out.NewMessages = append(out.NewMessages, msg)
addMessageUsers(out, seenUsers, event.Message)
}
if balance := tgGiftStarsBalanceUpdate(event.Message); balance != nil {
out.OtherUpdates = append(out.OtherUpdates, balance)
}
case domain.UpdateEventReadHistoryInbox:
if update := tgReadHistoryInboxUpdate(event); update != nil {
out.OtherUpdates = append(out.OtherUpdates, update)

View file

@ -507,6 +507,9 @@ func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, rando
Pts: event.Pts,
PtsCount: event.PtsCount,
})
if balance := tgGiftStarsBalanceUpdate(msg); balance != nil {
updates = append(updates, balance)
}
date := event.Date
if date == 0 {
date = msg.Date
@ -520,6 +523,18 @@ func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, rando
}
}
func tgGiftStarsBalanceUpdate(msg domain.Message) tg.UpdateClass {
if msg.Out || msg.Media == nil || msg.Media.ServiceAction == nil ||
msg.Media.ServiceAction.Kind != domain.MessageServiceActionGiftStars {
return nil
}
action := msg.Media.ServiceAction.GiftStars
if action == nil || action.BalanceAfter < 0 {
return nil
}
return &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: action.BalanceAfter}}
}
// tgPrivateSendResultUpdates returns a complete send acknowledgement for exact
// random_id replays. DrKLO requires UpdateNewMessage in an Updates response to
// transition its local pending message to SENT. Visible edited messages use the

View file

@ -15,6 +15,9 @@ import (
// registerPayments 注册 payments.* RPCStars 本地账本(余额/流水真实化)+ 其余
// gift/auction/revenue 第一阶段兼容桩。
func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
registerRPC[*tg.PaymentsGetStarsGiftOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsGiftOptions, func(ctx context.Context, req *tg.PaymentsGetStarsGiftOptionsRequest) (any, error) {
return r.onPaymentsGetStarsGiftOptions(ctx, req)
})
registerRPC[*tg.PaymentsGetStarsTopupOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTopupOptions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTopupOptionsRequest) (any,
// premium 订阅赠送 telesrv 不实现无支付流返回空选项。关键作用TDesktop 送礼框
@ -66,6 +69,9 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
registerRPC[*tg.PaymentsSendStarsFormRequest](d, tlprofile.SemanticMethodPaymentsSendStarsForm, func(ctx context.Context, layerRequest *tg.PaymentsSendStarsFormRequest) (any, error) {
return r.onPaymentsSendStarsForm(ctx, layerRequest)
})
registerRPC[*tg.PaymentsSendPaymentFormRequest](d, tlprofile.SemanticMethodPaymentsSendPaymentForm, func(ctx context.Context, req *tg.PaymentsSendPaymentFormRequest) (any, error) {
return r.onPaymentsSendPaymentForm(ctx, req)
})
registerRPC[*tg.PaymentsGetSavedStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetSavedStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetSavedStarGiftsRequest) (any, error) {
return r.onPaymentsGetSavedStarGifts(ctx, layerRequest)
})

View file

@ -30,6 +30,82 @@ func devStarsTopupOptions() []tg.StarsTopupOption {
}
}
func devStarsGiftOptions() []tg.StarsGiftOption {
// No store_product is advertised: telesrv has no Google/Apple product and
// sideloaded Android clients must use the invoice checkout path.
return []tg.StarsGiftOption{
{Stars: 1000, Currency: "USD", Amount: 99},
{Stars: 2500, Currency: "USD", Amount: 199},
{Stars: 5000, Currency: "USD", Amount: 399},
}
}
type starsGiftPurchaseService interface {
IssueGiftPurchaseForm(context.Context, domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error)
PurchaseGift(context.Context, domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error)
}
func userGiftUnavailableErr() error { return tgerr.New(400, "USER_GIFT_UNAVAILABLE") }
func (r *Router) onPaymentsGetStarsGiftOptions(ctx context.Context, req *tg.PaymentsGetStarsGiftOptionsRequest) ([]tg.StarsGiftOption, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if req != nil {
if input, ok := req.GetUserID(); ok {
if _, err := r.starsGiftRecipient(ctx, userID, input); err != nil {
return nil, err
}
}
}
return devStarsGiftOptions(), nil
}
func (r *Router) starsGiftRecipient(ctx context.Context, buyerUserID int64, input tg.InputUserClass) (domain.User, error) {
if inputUserIsEmpty(input) || r.deps.Users == nil {
return domain.User{}, userIDInvalidErr()
}
recipient, found, err := r.userFromInput(ctx, buyerUserID, input)
if err != nil {
return domain.User{}, internalErr()
}
if !found || recipient.ID <= 0 || recipient.Deleted {
return domain.User{}, userIDInvalidErr()
}
if recipient.ID == buyerUserID {
return domain.User{}, userGiftUnavailableErr()
}
blocked, err := r.peerBlocksUser(ctx, buyerUserID, recipient.ID)
if err != nil {
return domain.User{}, err
}
if blocked {
return domain.User{}, userGiftUnavailableErr()
}
return recipient, nil
}
func validateStarsGiftOption(purpose *tg.InputStorePaymentStarsGift) (tg.StarsGiftOption, error) {
if purpose == nil || purpose.UserID == nil || purpose.Stars <= 0 || purpose.Amount <= 0 || purpose.Currency == "" {
return tg.StarsGiftOption{}, starsAmountInvalidErr()
}
for _, option := range devStarsGiftOptions() {
if option.Stars == purpose.Stars && option.Currency == purpose.Currency && option.Amount == purpose.Amount {
return option, nil
}
}
return tg.StarsGiftOption{}, starsFormAmountMismatchErr()
}
func starsGiftPurpose(inv *tg.InputInvoiceStars) (*tg.InputStorePaymentStarsGift, bool) {
if inv == nil {
return nil, false
}
purpose, ok := inv.Purpose.(*tg.InputStorePaymentStarsGift)
return purpose, ok && purpose != nil
}
// onPaymentsGetStarGifts 返回可购买礼物目录hash 命中返回 NotModified
func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) {
if r.deps.Gifts == nil {
@ -71,6 +147,9 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
}
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
if purpose, gift := starsGiftPurpose(inv); gift {
return r.starsGiftPaymentForm(ctx, userID, purpose)
}
purpose, ok := starsTopupPurpose(inv)
if !ok {
return nil, notImplementedErr()
@ -149,6 +228,36 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
}, 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
}
recipient, err := r.starsGiftRecipient(ctx, buyerUserID, purpose.UserID)
if err != nil {
return nil, err
}
service, ok := r.deps.Stars.(starsGiftPurchaseService)
if !ok {
return nil, notImplementedErr()
}
now := int(r.clock.Now().Unix())
form, err := service.IssueGiftPurchaseForm(ctx, domain.StarsGiftPurchaseForm{
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)
}
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
}
// onPaymentsSendStarsForm 处理 star gift 与 Stars topup
// - star gift: Debit→投递/记账,失败补偿退款。
// - topup: 校验测试包白名单→Credit 本地账本。
@ -165,6 +274,9 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
}
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 inv, ok := req.Invoice.(*tg.InputInvoiceStarGiftUpgrade); ok {
@ -266,6 +378,78 @@ 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.
func (r *Router) onPaymentsSendPaymentForm(ctx context.Context, req *tg.PaymentsSendPaymentFormRequest) (tg.PaymentsPaymentResultClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
inv, ok := req.Invoice.(*tg.InputInvoiceStars)
if !ok {
return nil, notImplementedErr()
}
purpose, ok := starsGiftPurpose(inv)
if !ok {
return nil, notImplementedErr()
}
if req.Credentials == nil {
return nil, tgerr.New(400, "PAYMENT_CREDENTIALS_INVALID")
}
if req.TipAmount != 0 {
return nil, tgerr.New(400, "TIP_AMOUNT_INVALID")
}
return r.sendStarsGiftPurchase(ctx, userID, req.FormID, purpose)
}
func (r *Router) sendStarsGiftPurchase(ctx context.Context, buyerUserID, formID int64, purpose *tg.InputStorePaymentStarsGift) (tg.PaymentsPaymentResultClass, error) {
if formID == 0 {
return nil, formIDEmptyErr()
}
if _, err := validateStarsGiftOption(purpose); err != nil {
return nil, err
}
recipient, err := r.starsGiftRecipient(ctx, buyerUserID, purpose.UserID)
if err != nil {
return nil, err
}
service, ok := r.deps.Stars.(starsGiftPurchaseService)
if !ok {
return nil, notImplementedErr()
}
result, err := service.PurchaseGift(ctx, domain.StarsGiftPurchaseRequest{
StarsGiftPurchaseForm: domain.StarsGiftPurchaseForm{
FormID: formID, 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)
}
updates := r.starGiftSendUpdates(ctx, buyerUserID, result.Send)
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
func starsGiftPurchaseErr(err error) error {
switch {
case errors.Is(err, domain.ErrStarsGiftFormExpired):
return tgerr.New(400, "FORM_EXPIRED")
case errors.Is(err, domain.ErrStarsGiftFormInvalid):
return starsFormAmountMismatchErr()
case errors.Is(err, domain.ErrStarsGiftUnavailable):
return userGiftUnavailableErr()
default:
return internalErr()
}
}
func (r *Router) sendStarGiftMemoryPurchase(ctx context.Context, userID int64, peer domain.Peer, gift domain.StarGift,
inv *tg.InputInvoiceStarGift, giftMessage string, upgradeStars int64) (tg.PaymentsPaymentResultClass, error) {
purchaseStars := gift.Stars + upgradeStars

View file

@ -0,0 +1,192 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appstars "telesrv/internal/app/stars"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type starsFriendGiftRPCStore struct {
*memory.StarsStore
issued domain.StarsGiftPurchaseForm
purchased domain.StarsGiftPurchaseRequest
purchases int
}
func (s *starsFriendGiftRPCStore) IssueStarsGiftPurchaseForm(_ context.Context, form domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) {
form.FormID = 70001
s.issued = form
return form, nil
}
func (s *starsFriendGiftRPCStore) PurchaseStarsGift(_ context.Context, req domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) {
s.purchased = req
s.purchases++
action := &domain.MessageServiceAction{Kind: domain.MessageServiceActionGiftStars, GiftStars: &domain.MessageGiftStarsAction{
Currency: req.Currency, Amount: req.Amount, Stars: req.Stars,
TransactionID: "stars-gift-test", BalanceAfter: 4321,
}}
sender := domain.Message{ID: 11, OwnerUserID: req.BuyerUserID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.BuyerUserID}, Out: true, Date: req.Date,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}}
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",
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},
RecipientEvent: domain.UpdateEvent{UserID: req.RecipientUserID, Type: domain.UpdateEventNewMessage, Pts: 9, PtsCount: 1, Date: req.Date, Message: recipient},
},
}, nil
}
func starsFriendGiftTestRouter(t *testing.T) (*Router, *starsFriendGiftRPCStore, domain.User, domain.User) {
t.Helper()
ctx := context.Background()
users := memory.NewUserStore()
buyer, err := users.Create(ctx, domain.User{AccessHash: 8101, Phone: "+15558101", FirstName: "Buyer"})
if err != nil {
t.Fatal(err)
}
recipient, err := users.Create(ctx, domain.User{AccessHash: 8102, Phone: "+15558102", FirstName: "Recipient"})
if err != nil {
t.Fatal(err)
}
st := &starsFriendGiftRPCStore{StarsStore: memory.NewStarsStore()}
r := New(Config{DC: 2}, Deps{
Users: appusers.NewService(users),
Stars: appstars.NewService(st, appstars.WithStartingGrant(0), appstars.WithGiftPurchaseStore(st)),
}, zaptest.NewLogger(t), clock.System)
return r, st, buyer, recipient
}
func TestStarsFriendGiftOptionsFormAndBothSettlementMethods(t *testing.T) {
r, st, buyer, recipient := starsFriendGiftTestRouter(t)
ctx := WithUserID(context.Background(), buyer.ID)
generic, err := r.onPaymentsGetStarsGiftOptions(ctx, &tg.PaymentsGetStarsGiftOptionsRequest{})
if err != nil || len(generic) != 3 || generic[0].Stars != 1000 || generic[0].Currency != "USD" || generic[0].Amount != 99 {
t.Fatalf("generic gift options = %+v err=%v", generic, err)
}
input := &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}
personalReq := &tg.PaymentsGetStarsGiftOptionsRequest{}
personalReq.SetUserID(input)
personal, err := r.onPaymentsGetStarsGiftOptions(ctx, personalReq)
if err != nil || len(personal) != len(generic) {
t.Fatalf("personal gift options = %+v err=%v", personal, err)
}
purpose := &tg.InputStorePaymentStarsGift{UserID: input, Stars: 2500, Currency: "USD", Amount: 199}
invoice := &tg.InputInvoiceStars{Purpose: purpose}
formClass, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
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 {
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 {
t.Fatalf("issued form = %+v", st.issued)
}
resultClass, err := r.onPaymentsSendStarsForm(ctx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: invoice})
if err != nil {
t.Fatalf("sendStarsForm gift: %v", err)
}
result, ok := resultClass.(*tg.PaymentsPaymentResult)
if !ok {
t.Fatalf("sendStarsForm result = %T", resultClass)
}
updates, ok := result.Updates.(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("sender updates = %T %+v", result.Updates, result.Updates)
}
newMessage, ok := updates.Updates[0].(*tg.UpdateNewMessage)
if !ok || newMessage.Pts != 5 || newMessage.PtsCount != 1 {
t.Fatalf("sender new message = %T %+v", updates.Updates[0], updates.Updates[0])
}
serviceMessage, ok := newMessage.Message.(*tg.MessageService)
if !ok {
t.Fatalf("gift message = %T", newMessage.Message)
}
action, ok := serviceMessage.Action.(*tg.MessageActionGiftStars)
if !ok || action.Stars != 2500 || action.Currency != "USD" || action.Amount != 199 || action.TransactionID != "" {
t.Fatalf("sender gift action = %T %+v", serviceMessage.Action, serviceMessage.Action)
}
if st.purchased.FormID != form.FormID || st.purchased.RecipientUserID != recipient.ID || st.purchases != 1 {
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 != 2 {
t.Fatalf("settlement method count = %d, want 2 fake invocations", st.purchases)
}
}
func TestStarsFriendGiftRejectsInvalidRecipientAndPackageBeforeStore(t *testing.T) {
r, st, buyer, recipient := starsFriendGiftTestRouter(t)
ctx := WithUserID(context.Background(), buyer.ID)
badRecipientReq := &tg.PaymentsGetStarsGiftOptionsRequest{}
badRecipientReq.SetUserID(&tg.InputUser{UserID: recipient.ID + 99999})
if _, err := r.onPaymentsGetStarsGiftOptions(ctx, badRecipientReq); !tgerr.Is(err, "USER_ID_INVALID") {
t.Fatalf("bad recipient err = %v", err)
}
bad := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsGift{
UserID: &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
Stars: 2500, Currency: "USD", Amount: 200,
}}
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") {
t.Fatalf("tampered package settle err = %v", err)
}
if st.issued.FormID != 0 || st.purchases != 0 {
t.Fatalf("invalid request reached store: issued=%+v purchases=%d", st.issued, st.purchases)
}
}
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,
}}
msg := domain.Message{ID: 4, OwnerUserID: 2, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Date: 1700000000,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: action}}
event := domain.UpdateEvent{UserID: 2, Type: domain.UpdateEventNewMessage, Pts: 8, PtsCount: 1, Date: msg.Date, Message: msg}
online := tgPrivateMessageUpdates(event, msg, 0, false, nil, nil)
if len(online.Updates) != 2 {
t.Fatalf("online updates = %+v", online.Updates)
}
balance, ok := online.Updates[1].(*tg.UpdateStarsBalance)
if !ok || balance.Balance.(*tg.StarsAmount).Amount != 3100 {
t.Fatalf("online balance = %T %+v", online.Updates[1], online.Updates[1])
}
diff := tgUpdatesDifference(2, domain.UpdateDifference{Events: []domain.UpdateEvent{event}, State: domain.UpdateState{Pts: 8}})
full, ok := diff.(*tg.UpdatesDifference)
if !ok || len(full.NewMessages) != 1 || len(full.OtherUpdates) != 1 {
t.Fatalf("difference = %T %+v", diff, diff)
}
if _, ok := full.OtherUpdates[0].(*tg.UpdateStarsBalance); !ok {
t.Fatalf("difference balance = %T", full.OtherUpdates[0])
}
}