removed all "paid" features - no more stars, gifts, or grams

This commit is contained in:
onysd 2026-08-07 01:50:10 +03:00
parent d4451d753c
commit 21d8e91756
165 changed files with 318 additions and 40948 deletions

View file

@ -1792,7 +1792,7 @@ func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.Emoji
if errors.Is(err, domain.ErrPremiumRequired) {
return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
}
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
if errors.Is(err, domain.ErrEmojiStatusCollectibleInvalid) {
return false, tgerr400("COLLECTIBLE_INVALID")
}
return false, internalErr()
@ -1842,29 +1842,9 @@ func (r *Router) domainUserEmojiStatus(ctx context.Context, userID int64, input
}
return value, nil
case *tg.InputEmojiStatusCollectible:
if r.deps.Gifts == nil || status.CollectibleID <= 0 {
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
}
gift, found, err := r.deps.Gifts.UniqueByID(ctx, status.CollectibleID)
if err != nil {
return domain.UserEmojiStatus{}, internalErr()
}
owner := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
if !found || gift.Owner != owner || gift.Burned || gift.OwnerAddress != "" {
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
}
collectible, valid := domain.CollectibleEmojiStatus(gift)
if !valid {
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
}
value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible}
if until, ok := status.GetUntil(); ok {
value.Until = until
}
if !value.Valid() {
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
}
return value, nil
// telesrv has no Star Gifts, so no account can ever own the collectible
// this would reference.
return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID")
default:
return domain.UserEmojiStatus{}, inputConstructorInvalidErr()
}
@ -1969,35 +1949,11 @@ func (r *Router) onAccountGetDefaultEmojiStatuses(ctx context.Context, hash int6
// owned unique gifts as complete emojiStatusCollectible values. The bounded
// list order and hash are stable, so Android can safely reuse its cache.
func (r *Router) onAccountGetCollectibleEmojiStatuses(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
if r.deps.Gifts == nil {
return tdesktop.CollectibleEmojiStatuses(), nil
}
gifts, err := r.deps.Gifts.ListUniqueByOwner(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, domain.MaxSavedStarGiftsLimit)
if err != nil {
return nil, internalErr()
}
ids := make([]int64, 0, len(gifts))
statuses := make([]tg.EmojiStatusClass, 0, len(gifts))
for _, gift := range gifts {
collectible, ok := domain.CollectibleEmojiStatus(gift)
if !ok {
continue
}
ids = append(ids, collectible.CollectibleID)
statuses = append(statuses, tgUserEmojiStatusValue(domain.UserEmojiStatus{
DocumentID: collectible.DocumentID,
Collectible: collectible,
}))
}
catalogHash := mediaCatalogHash(ids)
if hash != 0 && hash == catalogHash {
return &tg.AccountEmojiStatusesNotModified{}, nil
}
return &tg.AccountEmojiStatuses{Hash: catalogHash, Statuses: statuses}, nil
// telesrv has no Star Gifts, so no account ever owns a collectible to list.
return tdesktop.CollectibleEmojiStatuses(), nil
}
func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) *tg.User {

View file

@ -1,134 +0,0 @@
package rpc
import (
"context"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type collectibleEmojiGiftService struct {
GiftsService
gifts map[int64]domain.UniqueStarGift
}
func (s *collectibleEmojiGiftService) UniqueByID(_ context.Context, id int64) (domain.UniqueStarGift, bool, error) {
gift, ok := s.gifts[id]
return gift, ok, nil
}
func (s *collectibleEmojiGiftService) ListUniqueByOwner(_ context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
out := make([]domain.UniqueStarGift, 0, len(s.gifts))
for _, gift := range s.gifts {
if gift.Owner == owner && !gift.Burned && gift.OwnerAddress == "" {
out = append(out, gift)
}
}
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func collectibleEmojiTestGift(ownerID int64) domain.UniqueStarGift {
return domain.UniqueStarGift{
ID: 9001, Title: "Plush Pepe", Slug: "PlushPepe-1",
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID},
Model: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7101}},
Pattern: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7201}},
Backdrop: domain.StarGiftCollectibleAttribute{
CenterColor: 0x102030, EdgeColor: 0x405060,
PatternColor: 0x708090, TextColor: 0xa0b0c0,
},
}
}
func TestAccountCollectibleEmojiStatusListSetAndRejectNonOwner(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550009101", FirstName: "Owner"})
if err != nil {
t.Fatal(err)
}
other, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550009102", FirstName: "Other"})
if err != nil {
t.Fatal(err)
}
users := appusers.NewService(userStore)
if _, err := users.GrantPremium(ctx, owner.ID, 1); err != nil {
t.Fatalf("grant premium: %v", err)
}
gift := collectibleEmojiTestGift(owner.ID)
gifts := &collectibleEmojiGiftService{gifts: map[int64]domain.UniqueStarGift{gift.ID: gift}}
r := New(Config{}, Deps{Users: users, Gifts: gifts}, zaptest.NewLogger(t), clock.System)
ownerCtx := WithUserID(ctx, owner.ID)
listed, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, 0)
if err != nil {
t.Fatalf("get collectible statuses: %v", err)
}
statuses, ok := listed.(*tg.AccountEmojiStatuses)
if !ok || len(statuses.Statuses) != 1 || statuses.Hash == 0 {
t.Fatalf("collectible list = %T %#v", listed, listed)
}
collectible, ok := statuses.Statuses[0].(*tg.EmojiStatusCollectible)
if !ok || collectible.CollectibleID != gift.ID || collectible.DocumentID != gift.Model.Document.ID ||
collectible.PatternDocumentID != gift.Pattern.Document.ID || collectible.PatternColor != gift.Backdrop.PatternColor {
t.Fatalf("collectible status = %T %#v", statuses.Statuses[0], statuses.Statuses[0])
}
if cached, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, statuses.Hash); err != nil {
t.Fatalf("get cached collectible statuses: %v", err)
} else if _, ok := cached.(*tg.AccountEmojiStatusesNotModified); !ok {
t.Fatalf("cached collectible statuses = %T, want notModified", cached)
}
input := &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID}
input.SetUntil(2_000_000_000)
if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, input); err != nil || !ok {
t.Fatalf("set collectible status: ok=%v err=%v", ok, err)
}
self, err := users.Self(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
if !self.EmojiStatusCollectible.Valid() || self.EmojiStatusCollectible.CollectibleID != gift.ID ||
self.EmojiStatusUntil != 2_000_000_000 {
t.Fatalf("persisted collectible status = %+v", self.EmojiStatus())
}
wire, ok := tgUserEmojiStatus(self, time.Now().Unix()).(*tg.EmojiStatusCollectible)
if !ok || wire.Slug != gift.Slug || wire.TextColor != gift.Backdrop.TextColor {
t.Fatalf("wire collectible = %T %#v", tgUserEmojiStatus(self, time.Now().Unix()), wire)
}
stolen := gift
stolen.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
gifts.gifts[gift.ID] = stolen
if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID}); ok || !tgerr.Is(err, "COLLECTIBLE_INVALID") {
t.Fatalf("set non-owned collectible: ok=%v err=%v", ok, err)
}
}
func TestCollectibleEmojiStatusDurableUpdateProjection(t *testing.T) {
collectible, ok := domain.CollectibleEmojiStatus(collectibleEmojiTestGift(1))
if !ok {
t.Fatal("test gift should project")
}
value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible}
update, ok := tgOtherUpdateFromEvent(domain.UpdateEvent{
UserID: 1, Type: domain.UpdateEventUserEmojiStatus, EmojiStatus: value,
}).(*tg.UpdateUserEmojiStatus)
if !ok {
t.Fatal("durable event did not produce updateUserEmojiStatus")
}
if status, ok := update.EmojiStatus.(*tg.EmojiStatusCollectible); !ok || status.PatternDocumentID != collectible.PatternDocumentID {
t.Fatalf("durable wire status = %T %#v", update.EmojiStatus, update.EmojiStatus)
}
}

View file

@ -1,272 +0,0 @@
package rpc
import (
"context"
"errors"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type fakeAccountRatingProjection struct {
byUser map[int64]domain.AccountRating
err error
ratingCalls int
ensureCalls int
}
func (f *fakeAccountRatingProjection) Rating(_ context.Context, userID int64) (domain.AccountRating, error) {
f.ratingCalls++
if f.err != nil {
return domain.AccountRating{}, f.err
}
rating, ok := f.byUser[userID]
if !ok {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
return rating, nil
}
// EnsureRating deliberately exists on the fake even though it is not part of
// AccountRatingService. The assertion below pins that profile reads stay
// read-only if a future implementation happens to expose a materializer.
func (f *fakeAccountRatingProjection) EnsureRating(_ context.Context, userID int64) (domain.AccountRating, error) {
f.ensureCalls++
return f.byUser[userID], nil
}
var _ AccountRatingService = (*fakeAccountRatingProjection)(nil)
func newAccountRatingProjectionFixture(t *testing.T, ratings AccountRatingService) (*Router, domain.User, domain.User) {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{
AccessHash: 11,
Phone: "15550004001",
FirstName: "Owner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
other, err := userStore.Create(ctx, domain.User{
AccessHash: 22,
Phone: "15550004002",
FirstName: "Other",
})
if err != nil {
t.Fatalf("create other: %v", err)
}
router := New(Config{}, Deps{
Users: appusers.NewService(userStore),
AccountRatings: ratings,
}, zaptest.NewLogger(t), clock.System)
return router, owner, other
}
func TestUserFullProjectsCompositeRatingReadOnlyAndCachesIt(t *testing.T) {
pendingDate := time.Unix(1800000000, 0).UTC()
ratings := &fakeAccountRatingProjection{byUser: make(map[int64]domain.AccountRating)}
router, owner, _ := newAccountRatingProjectionFixture(t, ratings)
ratings.byUser[owner.ID] = domain.AccountRating{
UserID: owner.ID,
Level: 3,
Stars: 1200,
CurrentLevelStars: domain.AccountRatingLevelThreshold(3),
NextLevelStars: domain.AccountRatingLevelThreshold(4),
HasNextLevel: true,
PendingStars: 500,
PendingDate: pendingDate,
}
ctx := WithUserID(context.Background(), owner.ID)
full, err := router.onUsersGetFullUser(ctx, &tg.InputUserSelf{})
if err != nil {
t.Fatalf("get self full user: %v", err)
}
rating, ok := full.FullUser.GetStarsRating()
if !ok || rating.Level != 3 || rating.Stars != 1200 ||
rating.CurrentLevelStars != domain.AccountRatingLevelThreshold(3) {
t.Fatalf("self rating = %+v (present=%v)", rating, ok)
}
if next, ok := rating.GetNextLevelStars(); !ok || next != domain.AccountRatingLevelThreshold(4) {
t.Fatalf("self next_level_stars = %d (present=%v)", next, ok)
}
pending, ok := full.FullUser.GetStarsMyPendingRating()
if !ok || pending.Stars != 1700 {
t.Fatalf("self pending rating = %+v (present=%v)", pending, ok)
}
if date, ok := full.FullUser.GetStarsMyPendingRatingDate(); !ok || date != int(pendingDate.Unix()) {
t.Fatalf("self pending date = %d (present=%v)", date, ok)
}
if ratings.ratingCalls != 1 || ratings.ensureCalls != 0 {
t.Fatalf("rating calls=%d ensure calls=%d, want 1/0", ratings.ratingCalls, ratings.ensureCalls)
}
// The second response is served from the existing UserFull projection cache:
// the rating read cannot become a per-request database query.
if _, err := router.onUsersGetFullUser(ctx, &tg.InputUserSelf{}); err != nil {
t.Fatalf("get cached self full user: %v", err)
}
if ratings.ratingCalls != 1 || ratings.ensureCalls != 0 {
t.Fatalf("cached rating calls=%d ensure calls=%d, want 1/0", ratings.ratingCalls, ratings.ensureCalls)
}
}
func TestUserFullRatingPendingIsSelfOnly(t *testing.T) {
pendingDate := time.Unix(1800000000, 0).UTC()
ratings := &fakeAccountRatingProjection{byUser: make(map[int64]domain.AccountRating)}
router, owner, other := newAccountRatingProjectionFixture(t, ratings)
ratings.byUser[other.ID] = domain.AccountRating{
UserID: other.ID,
Level: 1,
Stars: 150,
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
NextLevelStars: domain.AccountRatingLevelThreshold(2),
HasNextLevel: true,
PendingStars: 500,
PendingDate: pendingDate,
}
full, err := router.onUsersGetFullUser(
WithUserID(context.Background(), owner.ID),
&tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash},
)
if err != nil {
t.Fatalf("get other full user: %v", err)
}
if rating, ok := full.FullUser.GetStarsRating(); !ok || rating.Level != 1 || rating.Stars != 150 {
t.Fatalf("other rating = %+v (present=%v)", rating, ok)
}
if _, ok := full.FullUser.GetStarsMyPendingRating(); ok {
t.Fatal("other pending rating is visible")
}
if _, ok := full.FullUser.GetStarsMyPendingRatingDate(); ok {
t.Fatal("other pending rating date is visible")
}
}
func TestUserFullRatingDegradesWithoutStoredProjection(t *testing.T) {
tests := []struct {
name string
ratings AccountRatingService
}{
{name: "service absent"},
{name: "row missing", ratings: &fakeAccountRatingProjection{byUser: map[int64]domain.AccountRating{}}},
{name: "read failure", ratings: &fakeAccountRatingProjection{
byUser: map[int64]domain.AccountRating{},
err: errors.New("rating unavailable"),
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router, owner, _ := newAccountRatingProjectionFixture(t, tt.ratings)
full, err := router.onUsersGetFullUser(
WithUserID(context.Background(), owner.ID),
&tg.InputUserSelf{},
)
if err != nil {
t.Fatalf("get full user: %v", err)
}
if _, ok := full.FullUser.GetStarsRating(); ok {
t.Fatal("rating set without a stored projection")
}
if _, ok := full.FullUser.GetStarsMyPendingRating(); ok {
t.Fatal("pending rating set without a stored projection")
}
})
}
}
func TestUserFullRatingOmitsBotsAndTopLevelThreshold(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
viewer, err := userStore.Create(ctx, domain.User{
AccessHash: 31,
Phone: "15550004101",
FirstName: "Viewer",
})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
bot, err := userStore.Create(ctx, domain.User{
AccessHash: 32,
Phone: "15550004102",
FirstName: "Helper",
Bot: true,
})
if err != nil {
t.Fatalf("create bot: %v", err)
}
ratings := &fakeAccountRatingProjection{byUser: map[int64]domain.AccountRating{
viewer.ID: {
UserID: viewer.ID,
Level: domain.MaxAccountRatingLevel,
Stars: domain.AccountRatingLevelThreshold(domain.MaxAccountRatingLevel),
CurrentLevelStars: domain.AccountRatingLevelThreshold(domain.MaxAccountRatingLevel),
},
bot.ID: {
UserID: bot.ID,
Level: 4,
Stars: 2000,
},
domain.OfficialSystemUserID: {
UserID: domain.OfficialSystemUserID,
Level: 5,
Stars: 3000,
},
}}
router := New(Config{}, Deps{
Users: appusers.NewService(userStore),
AccountRatings: ratings,
}, zaptest.NewLogger(t), clock.System)
viewerCtx := WithUserID(ctx, viewer.ID)
own, err := router.onUsersGetFullUser(viewerCtx, &tg.InputUserSelf{})
if err != nil {
t.Fatalf("get own full user: %v", err)
}
rating, ok := own.FullUser.GetStarsRating()
if !ok || rating.Level != domain.MaxAccountRatingLevel {
t.Fatalf("top-level rating = %+v (present=%v)", rating, ok)
}
if _, ok := rating.GetNextLevelStars(); ok {
t.Fatal("next_level_stars set at the maximum level")
}
botFull, err := router.onUsersGetFullUser(
viewerCtx,
&tg.InputUser{UserID: bot.ID, AccessHash: bot.AccessHash},
)
if err != nil {
t.Fatalf("get bot full user: %v", err)
}
if _, ok := botFull.FullUser.GetStarsRating(); ok {
t.Fatal("bot rating is visible")
}
official, err := router.onUsersGetFullUser(
viewerCtx,
&tg.InputUser{
UserID: domain.OfficialSystemUserID,
AccessHash: domain.OfficialSystemUser().AccessHash,
},
)
if err != nil {
t.Fatalf("get official system user: %v", err)
}
if _, ok := official.FullUser.GetStarsRating(); ok {
t.Fatal("system account rating is visible")
}
// One read for the ratable viewer and none for the bot/system guards.
if ratings.ratingCalls != 1 {
t.Fatalf("rating calls = %d, want 1", ratings.ratingCalls)
}
}

View file

@ -3,8 +3,6 @@ package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
@ -34,19 +32,6 @@ func (r *Router) NotifyChannelChanged(ctx context.Context, ch domain.Channel) er
return nil
}
// NotifyStarsBalanceChanged is the domain-only hook used by the internal Admin
// API after the local Stars ledger balance has changed outside a client RPC.
func (r *Router) NotifyStarsBalanceChanged(ctx context.Context, balance domain.StarsBalance) error {
if r == nil || balance.UserID == 0 {
return nil
}
r.pushUserUpdates(ctx, balance.UserID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}},
Date: int(r.clock.Now().Unix()),
})
return nil
}
// NotifyAccountFreezeChanged invalidates target-scoped projections immediately
// and wakes the durable audience nudge worker. Cross-instance cache invalidation
// is also carried by the committed user_visibility read-model notification.

View file

@ -171,7 +171,6 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
if err := r.applyTranslationDisabledToChannelFull(ctx, userID, ref.ID, &full); err != nil {
return nil, err
}
r.applyStarGiftsCountToChannelFull(ctx, ref.ID, &full)
r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, ref.ID, &full)
r.applyNotifySettingsToChannelFull(ctx, userID, ref.ID, &full)
r.applyBotVerificationToChannelFull(ctx, ref.ID, &full)
@ -192,7 +191,6 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
return nil, err
}
full := tgChannelFull(view, r.cfg.PublicBaseURL)
r.applyStarGiftsCountToChannelFull(ctx, view.Channel.ID, full)
userIDs := []int64{view.Channel.CreatorUserID, view.Self.UserID}
// 注:Bots 过滤实际会返回群内 bot(TestGroupBotRPCShape 覆盖),这里据此富化 full.BotInfo。
// (此前审计误判为死代码,已由单测纠正——勿删。)
@ -237,16 +235,6 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
}, nil
}
func (r *Router) applyStarGiftsCountToChannelFull(ctx context.Context, channelID int64, full *tg.ChannelFull) {
if r.deps.Gifts == nil || channelID == 0 || full == nil {
return
}
n, err := r.deps.Gifts.CountSaved(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
if err == nil && n > 0 {
full.SetStargiftsCount(n)
}
}
type channelReadModelResolver interface {
GetChannelReadModel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
}

View file

@ -175,12 +175,6 @@ func TestBroadcastChannelAcceptsFullReactionCatalog(t *testing.T) {
if !ok || len(some.Reactions) != catalogSize {
t.Fatalf("full channel reactions = %#v, want %d explicit reactions", stored, catalogSize)
}
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) {
@ -230,9 +224,6 @@ func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
if fullChannel.ReactionsLimit != 7 {
t.Fatalf("reactions limit after omitted flag = %d, want preserved 7", fullChannel.ReactionsLimit)
}
if !fullChannel.GetPaidReactionsAvailable() {
t.Fatalf("paid reactions after omitted flag = false, want preserved true")
}
disablePaid := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer,
@ -253,12 +244,6 @@ func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
if fullChannel.ReactionsLimit != 7 {
t.Fatalf("reactions limit after paid-only update = %d, want preserved 7", fullChannel.ReactionsLimit)
}
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) {
@ -299,10 +284,6 @@ func TestSetChatAvailableReactionsStripsTDesktopPaidSentinel(t *testing.T) {
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))
@ -336,81 +317,6 @@ func TestSetChatAvailableReactionsStripsTDesktopPaidSentinel(t *testing.T) {
}
}
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) {
ctx := context.Background()
userStore := memory.NewUserStore()

View file

@ -304,10 +304,6 @@ func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageAction
BroadcastMessagesAllowed: action.BroadcastMessagesAllowed,
Stars: action.Stars,
}
case domain.ChannelActionStarGift:
return tgMessageActionStarGift(action.StarGift)
case domain.ChannelActionStarGiftUnique:
return tgMessageActionStarGiftUnique(action.StarGiftUnique)
case domain.ChannelActionSetChatWallpaper:
if wallpaper := tgWallpaper(action.Wallpaper); wallpaper != nil {
return &tg.MessageActionSetChatWallPaper{Wallpaper: wallpaper}
@ -649,19 +645,6 @@ 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.Broadcast && !ch.Megagroup && ch.ReactionPolicy.PaidEnabled {
full.SetPaidReactionsAvailable(true)
}
return full
}

View file

@ -242,90 +242,11 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
ButtonID: shared.ButtonID,
Peers: tgPeerList(shared.Peers),
}
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:
action := m.ServiceAction.StarGiftOffer
if action == nil {
return &tg.MessageActionEmpty{}
}
return &tg.MessageActionStarGiftPurchaseOffer{Accepted: action.Accepted, Declined: action.Declined,
Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price), ExpiresAt: action.ExpiresAt}
case domain.MessageServiceActionStarGiftOfferDeclined:
action := m.ServiceAction.StarGiftOfferDeclined
if action == nil {
return &tg.MessageActionEmpty{}
}
return &tg.MessageActionStarGiftPurchaseOfferDeclined{Expired: action.Expired,
Gift: tgUniqueStarGift(action.Gift), Price: tgStarGiftAmount(action.Price)}
default:
return &tg.MessageActionEmpty{}
}
}
func tgMessageActionStarGiftUnique(action *domain.MessageStarGiftUniqueAction) tg.MessageActionClass {
if action == nil {
return &tg.MessageActionEmpty{}
}
out := &tg.MessageActionStarGiftUnique{
Upgrade: action.Upgrade, Saved: action.Saved, PrepaidUpgrade: action.PrepaidUpgrade,
Transferred: action.Transferred, Refunded: action.Refunded, Assigned: action.Assigned,
FromOffer: action.FromOffer, Craft: action.Craft,
Gift: tgUniqueStarGift(action.Gift),
}
if action.CanExportAt > 0 {
out.SetCanExportAt(action.CanExportAt)
}
if action.TransferStars > 0 {
out.SetTransferStars(action.TransferStars)
}
if action.ResaleAmount != nil {
out.SetResaleAmount(tgStarGiftAmount(*action.ResaleAmount))
}
if action.CanTransferAt > 0 {
out.SetCanTransferAt(action.CanTransferAt)
}
if action.CanResellAt > 0 {
out.SetCanResellAt(action.CanResellAt)
}
if action.DropOriginalDetailsStars > 0 {
out.SetDropOriginalDetailsStars(action.DropOriginalDetailsStars)
}
// Channel Craft is not executable yet. Gate on the authoritative gift owner
// as a final wire boundary so historical JSON/admin-log actions or a future
// constructor cannot accidentally expose Android's Craft entry marker.
if action.Gift.Owner.Type == domain.PeerTypeUser && action.CanCraftAt > 0 {
out.SetCanCraftAt(action.CanCraftAt)
}
if action.FromUserID != 0 {
out.SetFromID(&tg.PeerUser{UserID: action.FromUserID})
}
if peer := tgPeer(action.Peer); peer != nil {
out.SetPeer(peer)
}
if action.SavedID != 0 {
out.SetSavedID(action.SavedID)
}
return out
}
func tgPeerList(peers []domain.Peer) []tg.PeerClass {
out := make([]tg.PeerClass, 0, len(peers))
for _, peer := range peers {
@ -511,11 +432,6 @@ func tgMessageReactions(viewerUserID int64, in *domain.ChannelMessageReactions)
out.SetRecentReactions(recent)
}
}
// 付费 reaction:注入 ReactionPaid 计数 + top reactors(My/chosen 由 in.Paid 的视角数据驱动,
// 调用方对他人视角已抹除 My/MyStars)。统一在此注入,覆盖所有频道消息读路径。
if in.Paid != nil {
injectPaidReaction(out, *in.Paid)
}
if out.Results == nil {
out.Results = []tg.ReactionCount{}
}

View file

@ -27,9 +27,6 @@ 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

@ -810,7 +810,6 @@ type ChannelsService interface {
// SetActiveCall / AppendCallServiceMessage 是群通话模块的频道侧挂接点。
SetActiveCall(ctx context.Context, channelID, callID, callAccessHash int64, notEmpty bool) (domain.Channel, error)
AppendCallServiceMessage(ctx context.Context, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error)
AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error
InviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error)
FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
}
@ -1004,16 +1003,6 @@ type UsernameRegistryService interface {
Collectible(ctx context.Context, username string) (domain.CollectibleUsername, error)
}
// AccountRatingService exposes the stored gramsrv composite rating used by the
// userFull rating projection.
//
// It is deliberately read-only at the RPC boundary: ratings are computed by the
// bounded background worker, while profile reads only fetch the latest stored
// projection. A nil service or a read failure leaves every rating flag unset.
type AccountRatingService interface {
Rating(ctx context.Context, userID int64) (domain.AccountRating, error)
}
// BotVerificationService is the third-party bot verification boundary
// (core.telegram.org/api/bots/verification): a verifier bot marking peers with its
// own icon and description, which official clients render as a badge distinct from
@ -1065,7 +1054,6 @@ type Deps struct {
Moderation ModerationService
Users UsersService
Usernames UsernameRegistryService
AccountRatings AccountRatingService
BotVerifications BotVerificationService
TelegramLogin TelegramLoginService
Updates UpdatesService
@ -1096,8 +1084,6 @@ type Deps struct {
Limiter RateLimiter
Metrics Metrics
SecretChats SecretChatService
Stars StarsService
Gifts GiftsService
Passkey PasskeyService
Themes ThemeService
}
@ -1127,71 +1113,6 @@ type PasskeyService interface {
Delete(ctx context.Context, userID int64, credentialID []byte) (bool, error)
}
// GiftsService 抽象 Star 礼物(app/stargifts):目录 + peer 收到的礼物实例 CRUD。
// 扣费/退款/服务消息投递由 rpc 层经 Stars 账本 + Messages.SendPrivateText 编排。
type GiftsService interface {
Catalog(ctx context.Context) ([]domain.StarGift, error)
CatalogHash(ctx context.Context) (int, error)
GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error)
GiftRevisionByID(ctx context.Context, revisionID int64) (domain.StarGift, bool, error)
CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
CollectiblePreviewSample(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error)
CollectibleAvailability(ctx context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error)
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error)
UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error)
ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error)
Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error)
UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error)
RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error)
ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error)
ListSavedFiltered(ctx context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error)
GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error)
ResolveSavedIDs(ctx context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error)
CountSaved(ctx context.Context, owner domain.Peer) (int, error)
ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error)
ConvertAggregate(ctx context.Context, req domain.StarGiftConvertRequest) (domain.StarGiftConvertResult, error)
ListCollections(ctx context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error)
CreateCollection(ctx context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error)
UpdateCollection(ctx context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error)
DeleteCollection(ctx context.Context, owner domain.Peer, collectionID int) (bool, error)
ReorderCollections(ctx context.Context, owner domain.Peer, collectionIDs []int) error
SetPinned(ctx context.Context, owner domain.Peer, savedGiftIDs []int64) error
ListResale(ctx context.Context, filter domain.StarGiftResaleFilter) (domain.StarGiftResalePage, error)
ValueInfo(ctx context.Context, uniqueGiftID int64) (domain.StarGiftValueInfo, error)
SetListing(ctx context.Context, req domain.StarGiftListingRequest) (domain.UniqueStarGift, error)
Transfer(ctx context.Context, req domain.StarGiftTransferRequest) (domain.StarGiftTransferResult, error)
PurchaseResale(ctx context.Context, req domain.StarGiftResalePurchaseRequest) (domain.StarGiftTransferResult, error)
SendOffer(ctx context.Context, req domain.StarGiftOfferRequest) (domain.StarGiftOfferResult, error)
ResolveOffer(ctx context.Context, req domain.StarGiftResolveOfferRequest) (domain.StarGiftOfferResult, error)
ListCraft(ctx context.Context, userID, giftID int64, offset string, limit int) (domain.SavedStarGiftPage, error)
Craft(ctx context.Context, req domain.StarGiftCraftRequest) (domain.StarGiftCraftResult, error)
AuctionState(ctx context.Context, userID, giftID int64, slug string, now int) (domain.StarGiftAuction, error)
ActiveAuctions(ctx context.Context, userID int64, now int) ([]domain.StarGiftAuction, error)
AuctionAcquired(ctx context.Context, userID, giftID int64) ([]domain.StarGiftAuctionAcquired, error)
BidAuction(ctx context.Context, req domain.StarGiftAuctionBidRequest) (domain.StarGiftAuction, domain.StarsBalance, error)
PrepaidUpgradeTarget(ctx context.Context, owner domain.Peer, hash string) (domain.SavedStarGift, int64, error)
PrepayUpgrade(ctx context.Context, req domain.StarGiftPrepaidUpgradeRequest) (domain.StarGiftPrepaidUpgradeResult, error)
DropOriginalDetails(ctx context.Context, req domain.StarGiftDropOriginalDetailsRequest) (domain.StarGiftDropOriginalDetailsResult, error)
SetNotifications(ctx context.Context, userID, channelID int64, enabled bool) error
Withdraw(ctx context.Context, req domain.StarGiftWithdrawalRequest) (domain.StarGiftWithdrawal, error)
TonBalance(ctx context.Context, userID int64) (int64, error)
TonTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error)
IssuePurchaseForm(ctx context.Context, form domain.StarGiftPurchaseForm) (domain.StarGiftPurchaseForm, error)
ValidatePurchaseForm(ctx context.Context, req domain.StarGiftPurchaseRequest) error
Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error)
}
// StarsService 抽象 Stars 本地账本(app/stars):余额查询、贷记/借记、流水分页。
// 借记原子且永不为负;余额不足返回 domain.ErrStarsInsufficient(rpc 经 starsErr
// 映射为 BALANCE_TOO_LOW)。getStarsStatus 首读时惰性授予起始余额。
type StarsService interface {
GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error)
Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error)
Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error)
ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error)
}
// SecretChatService 抽象私聊端对端加密(Secret Chat)握手状态机(app/secretchat)。
// 服务端是盲中继;错误集合见 domain.ErrSecretChat* 与 app/secretchat.ErrGAInvalid
// (rpc 层经 secretChatErr 映射为 ENCRYPTION_* / CHAT_ID_INVALID / DH_G_A_INVALID)。

View file

@ -300,11 +300,6 @@ func (r *Router) monoforumSendUpdates(ctx context.Context, userID int64, mono do
newMsg.Message = &tg.MessageEmpty{ID: res.Message.ID}
}
updates = append(updates, newMsg)
if res.SenderStarsBalance != nil && res.Message.SenderUserID == userID {
updates = append(updates, &tg.UpdateStarsBalance{
Balance: &tg.StarsAmount{Amount: res.SenderStarsBalance.Balance},
})
}
date := int(r.clock.Now().Unix())
if res.Duplicate && res.ReplayDeleteEvent != nil {
if deleted := tgChannelUpdate(userID, *res.ReplayDeleteEvent); deleted != nil {

View file

@ -296,12 +296,6 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
if storedDraft.Message != "pending suggested post" || storedDraft.SuggestedPost == nil || storedDraft.SuggestedPost.Price == nil || storedDraft.SuggestedPost.Price.Amount != 10 || storedDraft.SuggestedPost.ScheduleDate != 1_700_100_000 {
t.Fatalf("persisted monoforum draft = %+v, want suggested post content", storedDraft)
}
tooLow := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "under-authorized", RandomID: 554}
tooLow.SetAllowPaidStars(9)
if _, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), tooLow); err == nil || !strings.Contains(err.Error(), "ALLOW_PAYMENT_REQUIRED") || !strings.Contains(err.Error(), "(10)") {
t.Fatalf("under-authorized paid message err = %v, want ALLOW_PAYMENT_REQUIRED_10", err)
}
// TDesktop 的订阅者请求不携带 InputReplyToMonoForum;服务端必须从调用者推导 saved_peer=self。
subReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "hi from sub telesrv://resolve?domain=Owner", RandomID: 555}
subReq.ClearDraft = true
@ -319,7 +313,7 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
t.Fatalf("subscriber send updates = %T, want *tg.Updates", subUpd)
}
var subMessageID int
var subPaidStars, subBalance int64
var subPaidStars int64
for _, update := range subUpdates.Updates {
if newMessage, ok := update.(*tg.UpdateNewChannelMessage); ok {
if message, ok := newMessage.Message.(*tg.Message); ok {
@ -336,36 +330,21 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
}
}
}
if balance, ok := update.(*tg.UpdateStarsBalance); ok {
if amount, ok := balance.Balance.(*tg.StarsAmount); ok {
subBalance = amount.Amount
}
}
}
if subMessageID == 0 || subPaidStars != 10 || subBalance != 990 {
t.Fatalf("subscriber send updates id/paid/balance = %d/%d/%d, want id>0/10/990: %#v", subMessageID, subPaidStars, subBalance, subUpdates.Updates)
// telesrv has no Stars economy: DM sends are always free, so no
// UpdateStarsBalance is ever emitted regardless of allow_paid_stars.
if subMessageID == 0 || subPaidStars != 0 {
t.Fatalf("subscriber send updates id/paid = %d/%d, want id>0/0: %#v", subMessageID, subPaidStars, subUpdates.Updates)
}
if _, found, err := dialogSvc.GetDraft(ctx, sub.ID, domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, 0); err != nil || found {
t.Fatalf("clear_draft after paid send found/err = %v/%v, want false/nil", found, err)
t.Fatalf("clear_draft after send found/err = %v/%v, want false/nil", found, err)
}
duplicateUpd, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), subReq)
if err != nil {
t.Fatalf("subscriber paid replay: %v", err)
t.Fatalf("subscriber replay: %v", err)
}
duplicateUpdates, ok := duplicateUpd.(*tg.Updates)
if !ok {
t.Fatalf("subscriber paid replay = %T, want *tg.Updates", duplicateUpd)
}
var duplicateBalance int64
for _, update := range duplicateUpdates.Updates {
if balance, ok := update.(*tg.UpdateStarsBalance); ok {
if amount, ok := balance.Balance.(*tg.StarsAmount); ok {
duplicateBalance = amount.Amount
}
}
}
if duplicateBalance != 990 {
t.Fatalf("subscriber paid replay balance = %d, want 990 without a second debit", duplicateBalance)
if _, ok := duplicateUpd.(*tg.Updates); !ok {
t.Fatalf("subscriber replay = %T, want *tg.Updates", duplicateUpd)
}
// 管理员回复到该订阅者的子会话:同一个 inputReplyToMessage 同时携带真实 reply id
@ -435,8 +414,10 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
if _, ok := top.Media.(*tg.MessageMediaContact); !ok {
t.Fatalf("history[0] media = %T, want MessageMediaContact", top.Media)
}
if paid, ok := top.GetPaidMessageStars(); !ok || paid != 10 {
t.Fatalf("media paid_message_stars = %d/%v, want actual configured price 10", paid, ok)
// telesrv has no Stars economy: sends are always free regardless of the
// channel's configured price, so paid_message_stars is never set.
if paid, ok := top.GetPaidMessageStars(); ok || paid != 0 {
t.Fatalf("media paid_message_stars = %d/%v, want 0/false", paid, ok)
}
topSuggested, ok := top.GetSuggestedPost()
if !ok {

View file

@ -1,112 +0,0 @@
package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
// channelPaidReactionService 是 r.deps.Channels 的可选扩展(app/channels.Service 实现),
// 用类型断言接入,避免在 ChannelsService 接口上为付费 reaction 单加方法。
type channelPaidReactionService interface {
SendPaidReaction(ctx context.Context, userID int64, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error)
}
// channelPaidReactionUpdates 为某 viewer 构造一条 updateMessageReactions:把消息已有的普通
// reaction 与付费 ReactionPaid(总星数 + top reactors)合并。非请求者走 min 语义(置 Min,
// 客户端忽略 chosen 保留本地态),避免把请求者视角串给他人。
func (r *Router) channelPaidReactionUpdates(ctx context.Context, requestUserID, viewerUserID int64, res domain.ChannelMessagePaidReactionResult, ids []int) *tg.Updates {
isRequester := viewerUserID == requestUserID
base := domain.ChannelMessageReactions{}
if res.Message.Reactions != nil {
base = *res.Message.Reactions
}
paid := clonePaidForViewer(res.Paid, isRequester)
base.Paid = &paid
mr := tgMessageReactions(viewerUserID, &base)
if mr == nil {
mr = &tg.MessageReactions{Results: []tg.ReactionCount{}}
}
if !isRequester {
mr.Min = true
}
msgID := res.Message.ID
if msgID == 0 && len(ids) > 0 {
msgID = ids[0]
}
update := &tg.UpdateMessageReactions{
Peer: &tg.PeerChannel{ChannelID: res.Channel.ID},
MsgID: msgID,
Reactions: *mr,
}
return &tg.Updates{
Updates: []tg.UpdateClass{update},
Users: r.tgUsersForIDs(ctx, viewerUserID, paidReactorUserIDs(res.Paid)),
Chats: tgChannels(viewerUserID, []domain.Channel{res.Channel}),
Date: int(r.clock.Now().Unix()),
Seq: 0,
}
}
// clonePaidForViewer 返回付费聚合的副本;非请求者抹除 My/MyStars(min 语义防串视角)。
func clonePaidForViewer(paid domain.ChannelMessagePaidReactions, isRequester bool) domain.ChannelMessagePaidReactions {
out := paid
out.TopReactors = make([]domain.PaidReactor, len(paid.TopReactors))
copy(out.TopReactors, paid.TopReactors)
if !isRequester {
out.MyStars = 0
out.MyAnonymous = false
for i := range out.TopReactors {
out.TopReactors[i].My = false
}
}
return out
}
// injectPaidReaction 把付费 reaction 注入 MessageReactions:ReactionPaid 计数置首位、填充
// top reactors 排行。My/chosen 完全由 paid 的视角数据驱动(调用方对他人视角已抹除 My/MyStars)。
func injectPaidReaction(mr *tg.MessageReactions, paid domain.ChannelMessagePaidReactions) {
if paid.TotalStars <= 0 {
return
}
paidCount := tg.ReactionCount{Reaction: &tg.ReactionPaid{}, Count: int(paid.TotalStars)}
if paid.MyStars > 0 {
// chosen_order 仅标记「本人已投」,具体序值不关键,置正。
paidCount.SetChosenOrder(int(paid.MyStars))
}
mr.Results = append([]tg.ReactionCount{paidCount}, mr.Results...)
reactors := make([]tg.MessageReactor, 0, len(paid.TopReactors))
for _, rr := range paid.TopReactors {
item := tg.MessageReactor{Count: int(rr.Stars)}
if rr.My {
item.My = true
} else {
item.Top = true
}
if rr.Anonymous {
item.Anonymous = true
}
// PeerID:非匿名给出;匿名仅本人给出(TL 语义:匿名本人 anonymous+peer 都置)。
if rr.UserID != 0 && (!rr.Anonymous || rr.My) {
item.SetPeerID(&tg.PeerUser{UserID: rr.UserID})
}
reactors = append(reactors, item)
}
if len(reactors) > 0 {
mr.SetTopReactors(reactors)
}
}
// paidReactorUserIDs 收集非匿名 reactor 的用户 id,供 Updates.Users 富化头像。
func paidReactorUserIDs(paid domain.ChannelMessagePaidReactions) []int64 {
ids := make([]int64, 0, len(paid.TopReactors))
for _, rr := range paid.TopReactors {
if rr.UserID != 0 && !rr.Anonymous {
ids = append(ids, rr.UserID)
}
}
return ids
}

View file

@ -1,109 +0,0 @@
package rpc
import (
"testing"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
// injectPaidReaction 请求者视角:ReactionPaid 置首、计数=总星、chosen 置位;
// TopReactors 含本人带 My + PeerID。
func TestInjectPaidReactionRequesterView(t *testing.T) {
paid := domain.ChannelMessagePaidReactions{
TotalStars: 150,
MyStars: 50,
TopReactors: []domain.PaidReactor{
{UserID: 2, Stars: 100, My: false},
{UserID: 1, Stars: 50, My: true},
},
}
mr := &tg.MessageReactions{Results: []tg.ReactionCount{}}
injectPaidReaction(mr, clonePaidForViewer(paid, true))
if len(mr.Results) != 1 {
t.Fatalf("results = %d, want 1 paid", len(mr.Results))
}
if _, ok := mr.Results[0].Reaction.(*tg.ReactionPaid); !ok {
t.Fatalf("results[0] reaction = %T, want *tg.ReactionPaid", mr.Results[0].Reaction)
}
if mr.Results[0].Count != 150 {
t.Fatalf("paid count = %d, want 150", mr.Results[0].Count)
}
if order, ok := mr.Results[0].GetChosenOrder(); !ok || order <= 0 {
t.Fatalf("requester chosen = %d ok %v, want set+positive (MyStars>0)", order, ok)
}
reactors, ok := mr.GetTopReactors()
if !ok || len(reactors) != 2 {
t.Fatalf("top reactors = %d ok %v, want 2", len(reactors), ok)
}
// 第二条是本人。
me := reactors[1]
if !me.My || me.Count != 50 {
t.Fatalf("my reactor = %+v, want My count 50", me)
}
if peer, ok := me.GetPeerID(); !ok {
t.Fatalf("my reactor peer = %v, want set", peer)
}
// 第一条是 top(非本人)。
if !reactors[0].Top || reactors[0].My {
t.Fatalf("top reactor = %+v, want Top not My", reactors[0])
}
}
// 非请求者视角:无 chosen、My 一律抹去(min 语义防串视角)。
func TestInjectPaidReactionOtherViewerScrubsMy(t *testing.T) {
paid := domain.ChannelMessagePaidReactions{
TotalStars: 150,
MyStars: 50,
TopReactors: []domain.PaidReactor{
{UserID: 2, Stars: 100, My: false},
{UserID: 1, Stars: 50, My: true},
},
}
mr := &tg.MessageReactions{Results: []tg.ReactionCount{}}
injectPaidReaction(mr, clonePaidForViewer(paid, false))
if _, ok := mr.Results[0].GetChosenOrder(); ok {
t.Fatalf("non-requester chosen must be unset")
}
reactors, _ := mr.GetTopReactors()
for i, rr := range reactors {
if rr.My {
t.Fatalf("reactor[%d] My must be scrubbed for non-requester: %+v", i, rr)
}
}
}
// 匿名 reactor(非本人):Anonymous 置位、不暴露 PeerID。
func TestInjectPaidReactionAnonymousHidesPeer(t *testing.T) {
paid := domain.ChannelMessagePaidReactions{
TotalStars: 100,
TopReactors: []domain.PaidReactor{{UserID: 9, Stars: 100, Anonymous: true, My: false}},
}
mr := &tg.MessageReactions{Results: []tg.ReactionCount{}}
injectPaidReaction(mr, clonePaidForViewer(paid, true))
reactors, ok := mr.GetTopReactors()
if !ok || len(reactors) != 1 {
t.Fatalf("reactors = %d ok %v, want 1", len(reactors), ok)
}
if !reactors[0].Anonymous {
t.Fatalf("reactor must be Anonymous: %+v", reactors[0])
}
if peer, ok := reactors[0].GetPeerID(); ok {
t.Fatalf("anonymous non-self reactor must not expose peer, got %v", peer)
}
}
// 空付费态:不注入任何 ReactionPaid。
func TestInjectPaidReactionEmpty(t *testing.T) {
mr := &tg.MessageReactions{Results: []tg.ReactionCount{}}
injectPaidReaction(mr, domain.ChannelMessagePaidReactions{})
if len(mr.Results) != 0 {
t.Fatalf("empty paid must inject nothing, got %d results", len(mr.Results))
}
if _, ok := mr.GetTopReactors(); ok {
t.Fatalf("empty paid must not set top reactors")
}
}

View file

@ -5,7 +5,6 @@ import (
"strings"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
@ -60,179 +59,3 @@ func (r *Router) validateDefaultReaction(ctx context.Context, reaction domain.Me
}
return reactionInvalidErr()
}
func (r *Router) onMessagesGetPaidReactionPrivacy(ctx context.Context) (tg.UpdatesClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
settings := domain.DefaultAccountReactionSettings()
if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok {
next, err := svc.GetReactionSettings(ctx, userID)
if err != nil {
return nil, internalErr()
}
settings = next
}
return &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdatePaidReactionPrivacy{
Private: r.tgPaidReactionPrivacy(ctx, userID, settings.PaidPrivacy),
}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
Seq: 0,
}, nil
}
func (r *Router) onMessagesTogglePaidReactionPrivacy(ctx context.Context, req *tg.MessagesTogglePaidReactionPrivacyRequest) (bool, error) {
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
return false, messageIDInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
return false, err
}
privacy, err := r.domainPaidReactionPrivacy(ctx, userID, req.Private)
if err != nil {
return false, err
}
if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok {
next, err := svc.SetPaidReactionPrivacy(ctx, userID, privacy)
if err != nil {
return false, internalErr()
}
privacy = next.PaidPrivacy
}
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdatePaidReactionPrivacy{Private: r.tgPaidReactionPrivacy(ctx, userID, privacy)}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
Seq: 0,
})
return true, nil
}
// onMessagesSendPaidReaction 为一条广播频道消息发送付费 reaction:从 Stars 账本 Debit
// req.Count 星,累计到消息上,返回带 updateMessageReactions(含 ReactionPaid 总星数 +
// top reactors)与 updateStarsBalance 的 Updates。崩溃约束:必须返回合法 Updates——
// DrKLO StarsController 对响应无 instanceof 强转 (TLRPC.Updates)。
func (r *Router) onMessagesSendPaidReaction(ctx context.Context, req *tg.MessagesSendPaidReactionRequest) (tg.UpdatesClass, error) {
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
if req.Count <= 0 || req.Count > domain.MaxPaidReactionStarsPerRequest {
return nil, starsAmountInvalidErr()
}
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
}
// 付费 reaction 仅用于(广播)频道帖子。
if peer.Type != domain.PeerTypeChannel {
return nil, peerIDInvalidErr()
}
anonymous, err := r.resolvePaidReactionAnonymous(ctx, userID, req)
if err != nil {
return nil, err
}
if r.deps.Stars == nil {
return nil, balanceTooLowErr()
}
paidSvc, ok := r.deps.Channels.(channelPaidReactionService)
if !ok {
return nil, notImplementedErr()
}
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: peer.ID}
// 1. 先从账本 Debit(余额不足→真实 BALANCE_TOO_LOW)。
balance, err := r.deps.Stars.Debit(ctx, userID, int64(req.Count), domain.StarsReasonReaction, channelPeer, "Paid reaction", "")
if err != nil {
return nil, starsErr(err)
}
// 2. 累计付费 reaction;失败则补偿退款。
res, err := paidSvc.SendPaidReaction(ctx, userID, domain.SendChannelPaidReactionRequest{
UserID: userID,
ChannelID: peer.ID,
MessageID: req.MsgID,
Stars: int64(req.Count),
Anonymous: anonymous,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
if _, refundErr := r.deps.Stars.Credit(ctx, userID, int64(req.Count), domain.StarsReasonReaction, channelPeer, "Paid reaction refund", ""); refundErr != nil {
r.log.Error("paid reaction refund failed after record error",
zap.Int64("user_id", userID), zap.Int64("channel_id", peer.ID), zap.Int("msg_id", req.MsgID), zap.Error(refundErr))
}
return nil, channelReactionErr(err)
}
// 3. 构建并扇出 updateMessageReactions;请求者额外带 updateStarsBalance。
ids := []int{res.Message.ID}
build := func(viewerUserID int64) *tg.Updates {
updates := r.channelPaidReactionUpdates(ctx, userID, viewerUserID, res, ids)
if updates != nil && viewerUserID == userID {
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}})
}
return updates
}
recipients := append([]int64{res.Message.SenderUserID}, res.Recipients...)
r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, recipients, build)
return build(userID), nil
}
// resolvePaidReactionAnonymous 计算本次付费 reaction 是否匿名:显式 private 优先,
// 缺省回退用户保存的默认付费 reaction 隐私。
func (r *Router) resolvePaidReactionAnonymous(ctx context.Context, userID int64, req *tg.MessagesSendPaidReactionRequest) (bool, error) {
if private, ok := req.GetPrivate(); ok {
privacy, err := r.domainPaidReactionPrivacy(ctx, userID, private)
if err != nil {
return false, err
}
return privacy.Kind == domain.PaidReactionPrivacyAnonymous, nil
}
if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok {
if settings, err := svc.GetReactionSettings(ctx, userID); err == nil {
return settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyAnonymous, nil
}
}
return false, nil
}
func (r *Router) domainPaidReactionPrivacy(ctx context.Context, userID int64, in tg.PaidReactionPrivacyClass) (domain.PaidReactionPrivacy, error) {
switch typed := in.(type) {
case nil, *tg.PaidReactionPrivacyDefault:
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyDefault}, nil
case *tg.PaidReactionPrivacyAnonymous:
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyAnonymous}, nil
case *tg.PaidReactionPrivacyPeer:
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, typed.Peer)
if err != nil {
return domain.PaidReactionPrivacy{}, err
}
return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyPeer, Peer: &peer}, nil
default:
return domain.PaidReactionPrivacy{}, inputConstructorInvalidErr()
}
}
func (r *Router) tgPaidReactionPrivacy(ctx context.Context, userID int64, in domain.PaidReactionPrivacy) tg.PaidReactionPrivacyClass {
switch in.Kind {
case domain.PaidReactionPrivacyAnonymous:
return &tg.PaidReactionPrivacyAnonymous{}
case domain.PaidReactionPrivacyPeer:
if in.Peer == nil {
return &tg.PaidReactionPrivacyDefault{}
}
if peer := r.inputPeerForDomainPeer(ctx, userID, *in.Peer); peer != nil {
return &tg.PaidReactionPrivacyPeer{Peer: peer}
}
}
return &tg.PaidReactionPrivacyDefault{}
}

View file

@ -903,15 +903,6 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
return r.onMessagesSetDefaultReaction(ctx, layerRequest.
Reaction)
})
registerRPC[*tg.MessagesGetPaidReactionPrivacyRequest](d, tlprofile.SemanticMethodMessagesGetPaidReactionPrivacy, func(ctx context.Context, layerRequest *tg.MessagesGetPaidReactionPrivacyRequest) (any, error) {
return r.onMessagesGetPaidReactionPrivacy(ctx)
})
registerRPC[*tg.MessagesTogglePaidReactionPrivacyRequest](d, tlprofile.SemanticMethodMessagesTogglePaidReactionPrivacy, func(ctx context.Context, layerRequest *tg.MessagesTogglePaidReactionPrivacyRequest) (any, error) {
return r.onMessagesTogglePaidReactionPrivacy(ctx, layerRequest)
})
registerRPC[*tg.MessagesSendPaidReactionRequest](d, tlprofile.SemanticMethodMessagesSendPaidReaction, func(ctx context.Context, layerRequest *tg.MessagesSendPaidReactionRequest) (any, error) {
return r.onMessagesSendPaidReaction(ctx, layerRequest)
})
registerRPC[*tg.MessagesDeleteParticipantReactionsRequest](d, tlprofile.SemanticMethodMessagesDeleteParticipantReactions, func(ctx context.Context, layerRequest *tg.MessagesDeleteParticipantReactionsRequest) (any, error) {
return r.onMessagesDeleteParticipantReactions(ctx, layerRequest)
})

View file

@ -258,12 +258,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
}
func messageSendErr(err error) error {
var paymentRequired *domain.StarsPaymentRequiredError
switch {
case errors.As(err, &paymentRequired) && paymentRequired.Stars > 0:
return allowPaymentRequiredErr(paymentRequired.Stars)
case errors.Is(err, domain.ErrStarsInsufficient):
return balanceTooLowErr()
case errors.Is(err, domain.ErrUserFrozen):
return frozenMethodInvalidErr()
case errors.Is(err, domain.ErrReplyMessageIDInvalid):
@ -507,9 +502,6 @@ 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
@ -523,18 +515,6 @@ 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

@ -25,9 +25,6 @@ func (r *Router) suggestedPostApprovalUpdates(ctx context.Context, viewerUserID
updates = append(updates, update)
}
}
if result.PayerStarsBalance != nil && result.PayerStarsBalance.UserID == viewerUserID {
updates = append(updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.PayerStarsBalance.Balance}})
}
chats := r.monoforumChats(ctx, viewerUserID, result.Monoforum)
if result.Parent.ID != 0 {
chats = appendUniqueTGChats(chats, tgChannelChatMin(viewerUserID, result.Parent))

View file

@ -196,35 +196,6 @@ func TestAccountWallpaperSeedLookupAndAckRPCs(t *testing.T) {
}
}
func TestPaymentsGetStarGiftCollectionsNoServiceFallbackAndValidatesPeer(t *testing.T) {
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), 1000000001)
var okReq bin.Buffer
if err := (&tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}}).Encode(&okReq); err != nil {
t.Fatalf("encode request: %v", err)
}
got, err := r.Dispatch(ctx, [8]byte{}, 0, &okReq)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
collections, ok := got.(*tg.PaymentsStarGiftCollections)
if !ok {
t.Fatalf("response type = %T, want *tg.PaymentsStarGiftCollections", got)
}
if len(collections.Collections) != 0 {
t.Fatalf("collections = %+v, want empty list", collections.Collections)
}
var badReq bin.Buffer
if err := (&tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerEmpty{}}).Encode(&badReq); err != nil {
t.Fatalf("encode bad request: %v", err)
}
if _, err := r.Dispatch(ctx, [8]byte{}, 0, &badReq); err == nil || !strings.Contains(err.Error(), "PEER_ID_INVALID") {
t.Fatalf("bad peer err = %v, want PEER_ID_INVALID", err)
}
}
func TestPaymentsGetStarsRevenueAdsAccountURLReturnsCompatURLAndValidatesPeer(t *testing.T) {
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), 1000000001)

View file

@ -2,19 +2,19 @@ package rpc
import (
"context"
"errors"
"strconv"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
// registerPayments 注册 payments.* RPC:Stars 本地账本(余额/流水真实化)+ 其余
// gift/auction/revenue 第一阶段兼容桩。
// registerPayments 注册 payments.* RPC。telesrv 不实现 Stars/Star Gift 经济:
// 大部分曾经的 Stars/gift 动作 RPC 已不再注册;仍注册的几个只读状态 RPC
// (getStarsStatus/Subscriptions/Transactions/RevenueStats)返回固定空值,
// 因为部分客户端界面会无条件加载它们——错误返回会导致界面卡死或崩溃,
// 空值则让界面正常渲染成"没有 Stars"。
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)
@ -22,24 +22,6 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
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 送礼框
// ShowStarGiftBox 的 ready() 门控要求 getPremiumGiftCodeOptions 成功返回(on_next)才置
// premiumGiftsReady=true,否则整框不弹出——此前返 NOT_IMPLEMENTED 导致点生日礼物无反应。
// 空列表即解门,星礼物正常发送;premium 区段另由 userFull.disallow_premium_gifts=true 隐藏。
error) {
return devStarsTopupOptions(), nil
})
registerRPC[*tg.PaymentsGetPremiumGiftCodeOptionsRequest](d, tlprofile.SemanticMethodPaymentsGetPremiumGiftCodeOptions, func(ctx context.Context, req *tg.PaymentsGetPremiumGiftCodeOptionsRequest) (any, error) {
return []tg.PremiumGiftCodeOption{}, nil
})
@ -52,110 +34,6 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
registerRPC[*tg.PaymentsGetStarsTransactionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsTransactions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsTransactionsRequest) (any, error) {
return r.onPaymentsGetStarsTransactions(ctx, layerRequest)
})
registerRPC[*tg.PaymentsCheckCanSendGiftRequest](d, tlprofile.SemanticMethodPaymentsCheckCanSendGift, func(ctx context.Context, req *tg.PaymentsCheckCanSendGiftRequest) (any, error) {
return r.onPaymentsCheckCanSendGift(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftActiveAuctionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftActiveAuctions, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftActiveAuctionsRequest) (any, error) {
return r.onPaymentsGetStarGiftActiveAuctions(ctx, layerRequest)
})
registerRPC[*tg.PaymentsGetStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGifts, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftsRequest) (any, error) {
return r.onPaymentsGetStarGifts(ctx, layerRequest.
Hash)
})
registerRPC[*tg.PaymentsGetStarGiftUpgradePreviewRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftUpgradePreview, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftUpgradePreviewRequest) (any, error) {
return r.onPaymentsGetStarGiftUpgradePreview(ctx, layerRequest.
GiftID)
})
registerRPC[*tg.PaymentsGetStarGiftUpgradeAttributesRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftUpgradeAttributes, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftUpgradeAttributesRequest) (any, error) {
return r.onPaymentsGetStarGiftUpgradeAttributes(ctx, layerRequest.GiftID)
})
registerRPC[*tg.PaymentsGetUniqueStarGiftRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGift, func(ctx context.Context, layerRequest *tg.PaymentsGetUniqueStarGiftRequest) (any, error) {
return r.onPaymentsGetUniqueStarGift(ctx, layerRequest.
Slug)
})
registerRPC[*tg.PaymentsGetUniqueStarGiftValueInfoRequest](d, tlprofile.SemanticMethodPaymentsGetUniqueStarGiftValueInfo, func(ctx context.Context, req *tg.PaymentsGetUniqueStarGiftValueInfoRequest) (any, error) {
return r.onPaymentsGetUniqueStarGiftValueInfo(ctx, req)
})
registerRPC[*tg.PaymentsGetResaleStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetResaleStarGifts, func(ctx context.Context, req *tg.PaymentsGetResaleStarGiftsRequest) (any, error) {
return r.onPaymentsGetResaleStarGifts(ctx, req)
})
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)
})
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)
})
registerRPC[*tg.PaymentsGetSavedStarGiftRequest](d, tlprofile.SemanticMethodPaymentsGetSavedStarGift, func(ctx context.Context, layerRequest *tg.PaymentsGetSavedStarGiftRequest) (any, error) {
return r.onPaymentsGetSavedStarGift(ctx, layerRequest.
Stargift)
})
registerRPC[*tg.PaymentsSaveStarGiftRequest](d, tlprofile.SemanticMethodPaymentsSaveStarGift, func(ctx context.Context, layerRequest *tg.PaymentsSaveStarGiftRequest) (any, error) {
return r.onPaymentsSaveStarGift(ctx, layerRequest)
})
registerRPC[*tg.PaymentsConvertStarGiftRequest](d, tlprofile.SemanticMethodPaymentsConvertStarGift, func(ctx context.Context, layerRequest *tg.PaymentsConvertStarGiftRequest) (any, error) {
return r.onPaymentsConvertStarGift(ctx, layerRequest.
Stargift)
})
registerRPC[*tg.PaymentsUpgradeStarGiftRequest](d, tlprofile.SemanticMethodPaymentsUpgradeStarGift, func(ctx context.Context, layerRequest *tg.PaymentsUpgradeStarGiftRequest) (any, error) {
return r.onPaymentsUpgradeStarGift(ctx, layerRequest)
})
registerRPC[*tg.PaymentsUpdateStarGiftPriceRequest](d, tlprofile.SemanticMethodPaymentsUpdateStarGiftPrice, func(ctx context.Context, req *tg.PaymentsUpdateStarGiftPriceRequest) (any, error) {
return r.onPaymentsUpdateStarGiftPrice(ctx, req)
})
registerRPC[*tg.PaymentsTransferStarGiftRequest](d, tlprofile.SemanticMethodPaymentsTransferStarGift, func(ctx context.Context, req *tg.PaymentsTransferStarGiftRequest) (any, error) {
return r.onPaymentsTransferStarGift(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftWithdrawalURLRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftWithdrawalURL, func(ctx context.Context, req *tg.PaymentsGetStarGiftWithdrawalURLRequest) (any, error) {
return r.onPaymentsGetStarGiftWithdrawalURL(ctx, req)
})
registerRPC[*tg.PaymentsSendStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsSendStarGiftOffer, func(ctx context.Context, req *tg.PaymentsSendStarGiftOfferRequest) (any, error) {
return r.onPaymentsSendStarGiftOffer(ctx, req)
})
registerRPC[*tg.PaymentsResolveStarGiftOfferRequest](d, tlprofile.SemanticMethodPaymentsResolveStarGiftOffer, func(ctx context.Context, req *tg.PaymentsResolveStarGiftOfferRequest) (any, error) {
return r.onPaymentsResolveStarGiftOffer(ctx, req)
})
registerRPC[*tg.PaymentsGetCraftStarGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetCraftStarGifts, func(ctx context.Context, req *tg.PaymentsGetCraftStarGiftsRequest) (any, error) {
return r.onPaymentsGetCraftStarGifts(ctx, req)
})
registerRPC[*tg.PaymentsCraftStarGiftRequest](d, tlprofile.SemanticMethodPaymentsCraftStarGift, func(ctx context.Context, req *tg.PaymentsCraftStarGiftRequest) (any, error) {
return r.onPaymentsCraftStarGift(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftAuctionStateRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionState, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionStateRequest) (any, error) {
return r.onPaymentsGetStarGiftAuctionState(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftAuctionAcquiredGifts, func(ctx context.Context, req *tg.PaymentsGetStarGiftAuctionAcquiredGiftsRequest) (any, error) {
return r.onPaymentsGetStarGiftAuctionAcquiredGifts(ctx, req)
})
registerRPC[*tg.PaymentsToggleChatStarGiftNotificationsRequest](d, tlprofile.SemanticMethodPaymentsToggleChatStarGiftNotifications, func(ctx context.Context, req *tg.PaymentsToggleChatStarGiftNotificationsRequest) (any, error) {
return r.onPaymentsToggleChatStarGiftNotifications(ctx, req)
})
registerRPC[*tg.PaymentsGetStarGiftCollectionsRequest](d, tlprofile.SemanticMethodPaymentsGetStarGiftCollections, func(ctx context.Context, layerRequest *tg.PaymentsGetStarGiftCollectionsRequest) (any, error) {
return r.onPaymentsGetStarGiftCollections(ctx, layerRequest)
})
registerRPC[*tg.PaymentsCreateStarGiftCollectionRequest](d, tlprofile.SemanticMethodPaymentsCreateStarGiftCollection, func(ctx context.Context, layerRequest *tg.PaymentsCreateStarGiftCollectionRequest) (any, error) {
return r.onPaymentsCreateStarGiftCollection(ctx, layerRequest)
})
registerRPC[*tg.PaymentsUpdateStarGiftCollectionRequest](d, tlprofile.SemanticMethodPaymentsUpdateStarGiftCollection, func(ctx context.Context, layerRequest *tg.PaymentsUpdateStarGiftCollectionRequest) (any, error) {
return r.onPaymentsUpdateStarGiftCollection(ctx, layerRequest)
})
registerRPC[*tg.PaymentsDeleteStarGiftCollectionRequest](d, tlprofile.SemanticMethodPaymentsDeleteStarGiftCollection, func(ctx context.Context, layerRequest *tg.PaymentsDeleteStarGiftCollectionRequest) (any, error) {
return r.onPaymentsDeleteStarGiftCollection(ctx, layerRequest)
})
registerRPC[*tg.PaymentsReorderStarGiftCollectionsRequest](d, tlprofile.SemanticMethodPaymentsReorderStarGiftCollections, func(ctx context.Context, layerRequest *tg.PaymentsReorderStarGiftCollectionsRequest) (any, error) {
return r.onPaymentsReorderStarGiftCollections(ctx, layerRequest)
})
registerRPC[*tg.PaymentsToggleStarGiftsPinnedToTopRequest](d, tlprofile.SemanticMethodPaymentsToggleStarGiftsPinnedToTop, func(ctx context.Context, layerRequest *tg.PaymentsToggleStarGiftsPinnedToTopRequest) (any, error) {
return r.onPaymentsToggleStarGiftsPinnedToTop(ctx, layerRequest)
})
registerRPC[*tg.PaymentsGetStarsRevenueAdsAccountURLRequest](d, tlprofile.SemanticMethodPaymentsGetStarsRevenueAdsAccountURL, func(ctx context.Context, layerRequest *tg.PaymentsGetStarsRevenueAdsAccountURLRequest) (any, error) {
peer := layerRequest.
Peer
@ -194,10 +72,9 @@ func (r *Router) onPaymentsAssignPlayMarketTransaction(ctx context.Context, _ *t
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
// revenue bucket is distinct from the general Stars balance and is not modeled.
// onPaymentsGetStarsRevenueStats: telesrv has no Stars/gift economy, so every
// peer gets the same zero-balance compatibility response (no channel ledger
// branch left to read from).
func (r *Router) onPaymentsGetStarsRevenueStats(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (*tg.PaymentsStarsRevenueStats, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
@ -206,319 +83,43 @@ func (r *Router) onPaymentsGetStarsRevenueStats(ctx context.Context, req *tg.Pay
if req == nil {
return nil, peerIDInvalidErr()
}
owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
return nil, err
}
ton := req.GetTon()
if owner.Type != domain.PeerTypeChannel {
return tdesktop.StarsRevenueStats(ton), nil
}
if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil {
return nil, err
}
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
if !ok {
return tdesktop.StarsRevenueStats(ton), nil
}
var balance int64
if ton {
balance, err = ledger.ChannelTonBalance(ctx, owner.ID)
} else {
balance, err = ledger.ChannelStarsBalance(ctx, owner.ID)
}
if err != nil {
return tdesktop.StarsRevenueStats(req.GetTon()), nil
}
// onPaymentsGetStarsStatus 无 Stars 账本,恒返回零余额(响应仍是合法的
// payments.starsStatus——两端客户端无条件读取 balance 字段)。
func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
stats := tdesktop.StarsRevenueStats(ton)
var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance}
if ton {
amount = &tg.StarsTonAmount{Amount: balance}
if req != nil && req.GetTon() {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
// Channel ledgers currently only receive collectible conversion/marketplace
// proceeds and have no withdrawal/debit path, so balance equals lifetime
// revenue. Withdrawal stays disabled because no external payout exists.
stats.Status.CurrentBalance = amount
stats.Status.AvailableBalance = amount
stats.Status.OverallRevenue = amount
return stats, nil
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
type channelGiftLedgerReader interface {
ChannelStarsBalance(ctx context.Context, channelID int64) (int64, error)
ChannelStarsTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error)
ChannelTonBalance(ctx context.Context, channelID int64) (int64, error)
ChannelTonTransactions(ctx context.Context, channelID int64, query domain.StarsTransactionQuery) (domain.TonTransactionPage, error)
}
// onPaymentsGetStarsStatus 返回请求 peer 的 Stars/本地 TON 余额。个人与频道账本
// 严格隔离;频道读取要求 Star Gift 管理权限,不能把频道收益投影到执行 RPC 的管理员。
// 响应必须是 payments.starsStatus(balance/chats/users 都是必填,空 vector 即可)——
// 两端客户端无条件读取 balance(DrKLO StarsAmount 反序列化 / TDesktop vbalance())。
func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) {
userID, owner, err := r.starGiftLedgerOwner(ctx, req)
if err != nil {
return nil, err
}
ton := req != nil && req.GetTon()
if owner.Type == domain.PeerTypeChannel {
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
if !ok {
if ton {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
var balance int64
if ton {
balance, err = ledger.ChannelTonBalance(ctx, owner.ID)
} else {
balance, err = ledger.ChannelStarsBalance(ctx, owner.ID)
}
if err != nil {
return nil, internalErr()
}
var amount tg.StarsAmountClass = &tg.StarsAmount{Amount: balance}
if ton {
amount = &tg.StarsTonAmount{Amount: balance}
}
out := emptyStarsStatus(amount)
out.Chats = r.tgChatsForChannelIDs(ctx, userID, []int64{owner.ID})
return out, nil
}
if ton {
if r.deps.Gifts == nil {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
balance, err := r.deps.Gifts.TonBalance(ctx, userID)
if err != nil {
return nil, internalErr()
}
return emptyStarsStatus(&tg.StarsTonAmount{Amount: balance}), nil
}
if r.deps.Stars == nil {
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
bal, err := r.deps.Stars.GetBalance(ctx, userID)
if err != nil {
return nil, starsErr(err)
}
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.
// onPaymentsGetStarsSubscriptions returns a zero balance and no subscriptions
// — telesrv never creates recurring Stars subscriptions.
func (r *Router) onPaymentsGetStarsSubscriptions(ctx context.Context, req *tg.PaymentsGetStarsSubscriptionsRequest) (*tg.PaymentsStarsStatus, error) {
if req == nil || len(req.Offset) > domain.MaxStarsTransactionsOffsetBytes {
return nil, inputRequestInvalidErr()
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
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
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
// onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。
// 末页必须省略 next_offset(flag 不置),否则 DrKLO 会无限翻页。
// onPaymentsGetStarsTransactions 无 Stars 账本,恒返回零余额、空流水(不设置
// next_offset,避免客户端无限翻页)。
func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) {
userID, owner, err := r.starGiftTransactionLedgerOwner(ctx, req)
if err != nil {
return nil, err
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
query, err := starsTransactionQuery(req)
if err != nil {
return nil, err
if req != nil && req.GetTon() {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
ton := req != nil && req.GetTon()
if owner.Type == domain.PeerTypeChannel {
ledger, ok := r.deps.Gifts.(channelGiftLedgerReader)
if !ok {
if ton {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
if ton {
page, err := ledger.ChannelTonTransactions(ctx, owner.ID, query)
if err != nil {
return nil, internalErr()
}
out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance})
if txns := tgTonTransactions(page.Transactions); len(txns) > 0 {
out.SetHistory(txns)
}
if page.NextOffset != "" {
out.SetNextOffset(page.NextOffset)
}
r.enrichChannelTonLedgerStatus(ctx, userID, owner.ID, page.Transactions, out)
return out, nil
}
page, err := ledger.ChannelStarsTransactions(ctx, owner.ID, query)
if err != nil {
return nil, internalErr()
}
out := emptyStarsStatus(&tg.StarsAmount{Amount: page.Balance})
if txns := tgStarsTransactions(page.Transactions); len(txns) > 0 {
out.SetHistory(txns)
}
if page.NextOffset != "" {
out.SetNextOffset(page.NextOffset)
}
r.enrichChannelStarsLedgerStatus(ctx, userID, owner.ID, page.Transactions, out)
return out, nil
}
if ton {
if r.deps.Gifts == nil {
return emptyStarsStatus(&tg.StarsTonAmount{}), nil
}
page, err := r.deps.Gifts.TonTransactions(ctx, userID, query)
if err != nil {
return nil, internalErr()
}
out := emptyStarsStatus(&tg.StarsTonAmount{Amount: page.Balance})
if txns := tgTonTransactions(page.Transactions); len(txns) > 0 {
out.SetHistory(txns)
}
if page.NextOffset != "" {
out.SetNextOffset(page.NextOffset)
}
ids := make([]int64, 0)
for _, txn := range page.Transactions {
if txn.Peer.Type == domain.PeerTypeUser {
ids = append(ids, txn.Peer.ID)
}
}
out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, uniqueInt64(ids)))
return out, nil
}
if r.deps.Stars == nil {
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
page, err := r.deps.Stars.ListTransactions(ctx, userID, query)
if err != nil {
return nil, starsErr(err)
}
out := emptyStarsStatus(&tg.StarsAmount{Amount: page.Balance})
if txns := tgStarsTransactions(page.Transactions); len(txns) > 0 {
out.SetHistory(txns)
}
if page.NextOffset != "" {
out.SetNextOffset(page.NextOffset)
}
// 富化流水中提到的用户对手方(频道对手方进 Chats 留待 paid reaction 阶段)。
if ids := starsTransactionUserIDs(page.Transactions); len(ids) > 0 {
out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, ids))
}
return out, nil
}
func starsTransactionQuery(req *tg.PaymentsGetStarsTransactionsRequest) (domain.StarsTransactionQuery, error) {
if req == nil {
return domain.StarsTransactionQuery{}, inputRequestInvalidErr()
}
inbound, outbound := req.GetInbound(), req.GetOutbound()
if inbound && outbound {
return domain.StarsTransactionQuery{}, inputRequestInvalidErr()
}
if _, ok := req.GetSubscriptionID(); ok {
// Stars subscriptions are not part of the current business model. Do not
// silently return the unfiltered ledger for a requested subscription.
return domain.StarsTransactionQuery{}, subscriptionIDInvalidErr()
}
direction := domain.StarsTransactionDirectionAll
if inbound {
direction = domain.StarsTransactionDirectionIncoming
} else if outbound {
direction = domain.StarsTransactionDirectionOutgoing
}
limit := req.Limit
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
}
return domain.StarsTransactionQuery{
Offset: req.Offset,
Limit: limit,
Direction: direction,
Ascending: req.GetAscending(),
}, nil
}
func (r *Router) starGiftLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (int64, domain.Peer, error) {
if req == nil {
return 0, domain.Peer{}, peerIDInvalidErr()
}
return r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
}
func (r *Router) starGiftTransactionLedgerOwner(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (int64, domain.Peer, error) {
if req == nil {
return 0, domain.Peer{}, peerIDInvalidErr()
}
return r.starGiftLedgerOwnerForPeer(ctx, req.Peer)
}
func (r *Router) starGiftLedgerOwnerForPeer(ctx context.Context, input tg.InputPeerClass) (int64, domain.Peer, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return 0, domain.Peer{}, internalErr()
}
owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
if err != nil {
return 0, domain.Peer{}, err
}
if owner.Type == domain.PeerTypeUser {
if owner.ID != userID {
return 0, domain.Peer{}, peerIDInvalidErr()
}
return userID, owner, nil
}
if err := r.checkStarGiftOwnerPermission(ctx, userID, owner); err != nil {
return 0, domain.Peer{}, err
}
return userID, owner, nil
}
func (r *Router) enrichChannelStarsLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.StarsTransaction, out *tg.PaymentsStarsStatus) {
userIDs := make([]int64, 0, len(txns))
channelIDs := []int64{ownerChannelID}
for _, txn := range txns {
switch txn.Peer.Type {
case domain.PeerTypeUser:
userIDs = append(userIDs, txn.Peer.ID)
case domain.PeerTypeChannel:
channelIDs = append(channelIDs, txn.Peer.ID)
}
}
out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs)))
out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs))
}
func (r *Router) enrichChannelTonLedgerStatus(ctx context.Context, viewerID, ownerChannelID int64, txns []domain.TonTransaction, out *tg.PaymentsStarsStatus) {
userIDs := make([]int64, 0, len(txns))
channelIDs := []int64{ownerChannelID}
for _, txn := range txns {
switch txn.Peer.Type {
case domain.PeerTypeUser:
userIDs = append(userIDs, txn.Peer.ID)
case domain.PeerTypeChannel:
channelIDs = append(channelIDs, txn.Peer.ID)
}
}
out.Users = tgUsersForViewer(viewerID, r.domainUsersForIDs(ctx, viewerID, uniqueInt64(userIDs)))
out.Chats = r.tgChatsForChannelIDs(ctx, viewerID, uniqueInt64(channelIDs))
return emptyStarsStatus(&tg.StarsAmount{}), nil
}
// emptyStarsStatus 构造一个合法的最小 payments.starsStatus(chats/users 非空 vector 但可空)。
@ -529,119 +130,3 @@ func emptyStarsStatus(balance tg.StarsAmountClass) *tg.PaymentsStarsStatus {
Users: []tg.UserClass{},
}
}
// tgStarsTransactions 把账本流水投影为 tg.StarsTransaction(amount 带符号:借记为负)。
func tgStarsTransactions(in []domain.StarsTransaction) []tg.StarsTransaction {
out := make([]tg.StarsTransaction, 0, len(in))
for _, t := range in {
item := tg.StarsTransaction{
ID: strconv.FormatInt(t.ID, 10),
Amount: &tg.StarsAmount{Amount: t.Amount},
Date: t.Date,
Peer: tgStarsTransactionPeer(t),
}
if t.Title != "" {
item.SetTitle(t.Title)
}
if t.Description != "" {
item.SetDescription(t.Description)
}
switch t.Reason {
case domain.StarsReasonReaction:
item.Reaction = true
case domain.StarsReasonPaidMessage:
item.SetPaidMessages(1)
case domain.StarsReasonGift:
item.Gift = true
case domain.StarsReasonGiftUpgrade:
item.StargiftUpgrade = true
case domain.StarsReasonGiftResale:
item.StargiftResale = true
case domain.StarsReasonGiftPrepaid:
item.StargiftPrepaidUpgrade = true
case domain.StarsReasonGiftDrop:
item.StargiftDropOriginalDetails = true
case domain.StarsReasonGiftAuction:
item.StargiftAuctionBid = true
case domain.StarsReasonGiftOffer:
item.Offer = true
}
out = append(out, item)
}
return out
}
func tgTonTransactions(in []domain.TonTransaction) []tg.StarsTransaction {
out := make([]tg.StarsTransaction, 0, len(in))
for _, t := range in {
item := tg.StarsTransaction{ID: strconv.FormatInt(t.ID, 10), Amount: &tg.StarsTonAmount{Amount: t.Amount},
Date: t.Date, Peer: tgStarsTransactionPeer(domain.StarsTransaction{Peer: t.Peer, Reason: t.Reason})}
if t.Amount > 0 {
item.Refund = true
}
if t.Title != "" {
item.SetTitle(t.Title)
}
if t.Description != "" {
item.SetDescription(t.Description)
}
switch t.Reason {
case domain.StarsReasonGiftResale:
item.StargiftResale = true
case domain.StarsReasonGiftOffer:
item.Offer = true
case domain.StarsReasonGiftAuction:
item.StargiftAuctionBid = true
case domain.StarsReasonGiftPrepaid:
item.StargiftPrepaidUpgrade = true
case domain.StarsReasonGiftDrop:
item.StargiftDropOriginalDetails = true
}
out = append(out, item)
}
return out
}
// tgStarsTransactionPeer 选择对手方构造器:grant/topup 走 Fragment(站外充值轨),
// 真实 peer 走 starsTransactionPeer,其余兜底 Unsupported(Peer 字段必填,不可为 nil)。
func tgStarsTransactionPeer(t domain.StarsTransaction) tg.StarsTransactionPeerClass {
switch t.Reason {
case domain.StarsReasonGrant, domain.StarsReasonTopup:
return &tg.StarsTransactionPeerFragment{}
}
if t.Peer.Type != "" && t.Peer.ID != 0 {
if p := tgPeer(t.Peer); p != nil {
return &tg.StarsTransactionPeer{Peer: p}
}
}
return &tg.StarsTransactionPeerUnsupported{}
}
// starsTransactionUserIDs 收集流水中去重的用户类对手方 id。
func starsTransactionUserIDs(in []domain.StarsTransaction) []int64 {
seen := make(map[int64]struct{}, len(in))
ids := make([]int64, 0, len(in))
for _, t := range in {
if t.Peer.Type != domain.PeerTypeUser || t.Peer.ID == 0 {
continue
}
if _, ok := seen[t.Peer.ID]; ok {
continue
}
seen[t.Peer.ID] = struct{}{}
ids = append(ids, t.Peer.ID)
}
return ids
}
// starsErr 把 Stars 账本领域错误映射为客户端可识别的 tgerr(仿 premiumBoostErr)。
func starsErr(err error) error {
switch {
case errors.Is(err, domain.ErrStarsInsufficient):
return balanceTooLowErr()
case errors.Is(err, domain.ErrStarsInvalidAmount):
return starsAmountInvalidErr()
default:
return internalErr()
}
}

View file

@ -1,98 +0,0 @@
package rpc
import (
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/domain"
)
func TestStarGiftCatalogProjectionKeepsSaleDatesBehindSoldOutFlag(t *testing.T) {
base := domain.StarGift{
ID: 8001,
RevisionID: 9001,
Stars: 100,
ConvertStars: 85,
Title: "Fresh Socks",
FirstSaleDate: 100,
LastSaleDate: 200,
Sticker: domain.Document{
ID: 700,
AccessHash: 7,
DCID: 2,
MimeType: "application/x-tgsticker",
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
},
}
tests := []struct {
name string
gift domain.StarGift
wantSoldOut bool
wantSaleDate bool
}{
{name: "unlimited live gift with operational sale history", gift: base},
{name: "limited live gift", gift: func() domain.StarGift {
gift := base
gift.Limited = true
gift.AvailabilityRemains = 9
gift.AvailabilityTotal = 10
return gift
}()},
{name: "sold out gift", gift: func() domain.StarGift {
gift := base
gift.Limited = true
gift.SoldOut = true
gift.AvailabilityTotal = 10
return gift
}(), wantSoldOut: true, wantSaleDate: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for _, profile := range []tlprofile.Profile{
tlprofile.Profile225,
tlprofile.Profile226,
tlprofile.Profile227,
tlprofile.Profile228,
} {
response := &tg.PaymentsStarGifts{
Hash: 1,
Gifts: []tg.StarGiftClass{tgStarGift(test.gift)},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
wire := &bin.Buffer{}
if err := tlprofile.EncodeObject(profile, response, wire); err != nil {
t.Fatalf("encode Layer %d catalog: %v", profile, err)
}
decodedObject, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Buf}, tlprofile.Limits{})
if err != nil {
t.Fatalf("decode Layer %d catalog: %v", profile, err)
}
decoded, ok := decodedObject.(*tg.PaymentsStarGifts)
if !ok || len(decoded.Gifts) != 1 {
t.Fatalf("decode Layer %d catalog = %T %#v", profile, decodedObject, decodedObject)
}
gift, ok := decoded.Gifts[0].(*tg.StarGift)
if !ok {
t.Fatalf("decode Layer %d gift = %T", profile, decoded.Gifts[0])
}
if gift.SoldOut != test.wantSoldOut {
t.Fatalf("Layer %d sold_out = %v, want %v", profile, gift.SoldOut, test.wantSoldOut)
}
first, firstSet := gift.GetFirstSaleDate()
last, lastSet := gift.GetLastSaleDate()
if firstSet != test.wantSaleDate || lastSet != test.wantSaleDate {
t.Fatalf("Layer %d sale date flags = (%v,%v), want %v", profile, firstSet, lastSet, test.wantSaleDate)
}
if test.wantSaleDate && (first != test.gift.FirstSaleDate || last != test.gift.LastSaleDate) {
t.Fatalf("Layer %d sale dates = (%d,%d), want (%d,%d)", profile, first, last, test.gift.FirstSaleDate, test.gift.LastSaleDate)
}
}
})
}
}

View file

@ -1,235 +0,0 @@
package rpc
import (
"context"
"errors"
"strings"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func (r *Router) onPaymentsGetStarGiftCollections(ctx context.Context, req *tg.PaymentsGetStarGiftCollectionsRequest) (tg.PaymentsStarGiftCollectionsClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if r.deps.Gifts == nil {
return &tg.PaymentsStarGiftCollections{Collections: []tg.StarGiftCollection{}}, nil
}
collections, err := r.deps.Gifts.ListCollections(ctx, owner)
if err != nil {
return nil, starGiftCollectionErr(err)
}
if req.Hash != 0 && req.Hash == domain.StarGiftCollectionsHash(collections) {
return &tg.PaymentsStarGiftCollectionsNotModified{}, nil
}
return &tg.PaymentsStarGiftCollections{Collections: tgStarGiftCollections(collections)}, nil
}
func (r *Router) onPaymentsCreateStarGiftCollection(ctx context.Context, req *tg.PaymentsCreateStarGiftCollectionRequest) (*tg.StarGiftCollection, error) {
if req == nil || r.deps.Gifts == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return nil, err
}
ids, err := r.resolveStarGiftCollectionRefs(ctx, userID, owner, req.Stargift)
if err != nil {
return nil, err
}
collection, err := r.deps.Gifts.CreateCollection(ctx, owner, strings.TrimSpace(req.Title), ids)
if err != nil {
return nil, starGiftCollectionErr(err)
}
r.invalidateStarGiftOwnerProjection(owner)
out := tgStarGiftCollection(collection)
return &out, nil
}
func (r *Router) onPaymentsUpdateStarGiftCollection(ctx context.Context, req *tg.PaymentsUpdateStarGiftCollectionRequest) (*tg.StarGiftCollection, error) {
if req == nil || req.CollectionID <= 0 || r.deps.Gifts == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return nil, err
}
patch := domain.StarGiftCollectionPatch{}
if title, ok := req.GetTitle(); ok {
title = strings.TrimSpace(title)
patch.Title = &title
}
if refs, ok := req.GetDeleteStargift(); ok {
patch.DeleteIDs, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
if err != nil {
return nil, err
}
}
if refs, ok := req.GetAddStargift(); ok {
patch.AddIDs, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
if err != nil {
return nil, err
}
}
if refs, ok := req.GetOrder(); ok {
patch.Order, err = r.resolveStarGiftCollectionRefs(ctx, userID, owner, refs)
if err != nil {
return nil, err
}
}
collection, err := r.deps.Gifts.UpdateCollection(ctx, owner, req.CollectionID, patch)
if err != nil {
return nil, starGiftCollectionErr(err)
}
r.invalidateStarGiftOwnerProjection(owner)
out := tgStarGiftCollection(collection)
return &out, nil
}
func (r *Router) onPaymentsDeleteStarGiftCollection(ctx context.Context, req *tg.PaymentsDeleteStarGiftCollectionRequest) (bool, error) {
if req == nil || req.CollectionID <= 0 || r.deps.Gifts == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return false, err
}
deleted, err := r.deps.Gifts.DeleteCollection(ctx, owner, req.CollectionID)
if err != nil {
return false, starGiftCollectionErr(err)
}
if !deleted {
return false, starGiftCollectionErr(domain.ErrStarGiftCollectionNotFound)
}
r.invalidateStarGiftOwnerProjection(owner)
return true, nil
}
func (r *Router) onPaymentsReorderStarGiftCollections(ctx context.Context, req *tg.PaymentsReorderStarGiftCollectionsRequest) (bool, error) {
if req == nil || r.deps.Gifts == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return false, err
}
if err := r.deps.Gifts.ReorderCollections(ctx, owner, req.Order); err != nil {
return false, starGiftCollectionErr(err)
}
return true, nil
}
func (r *Router) onPaymentsToggleStarGiftsPinnedToTop(ctx context.Context, req *tg.PaymentsToggleStarGiftsPinnedToTopRequest) (bool, error) {
if req == nil || r.deps.Gifts == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if err := r.ensureCanManageStarGiftOwner(ctx, userID, owner); err != nil {
return false, err
}
ids, err := r.resolveStarGiftCollectionRefs(ctx, userID, owner, req.Stargift)
if err != nil {
return false, err
}
if err := r.deps.Gifts.SetPinned(ctx, owner, ids); err != nil {
return false, starGiftCollectionErr(err)
}
r.invalidateStarGiftOwnerProjection(owner)
return true, nil
}
func (r *Router) resolveStarGiftCollectionRefs(ctx context.Context, userID int64, owner domain.Peer, refs []tg.InputSavedStarGiftClass) ([]int64, error) {
if len(refs) > domain.MaxStarGiftCollectionItems {
return nil, inputRequestInvalidErr()
}
domainRefs := make([]domain.SavedStarGiftRef, 0, len(refs))
for _, input := range refs {
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
if err != nil {
return nil, err
}
if !ok || ref.Owner != owner {
return nil, starGiftInvalidErr()
}
domainRefs = append(domainRefs, ref)
}
ids, err := r.deps.Gifts.ResolveSavedIDs(ctx, owner, domainRefs)
if err != nil {
return nil, starGiftCollectionErr(err)
}
return ids, nil
}
func tgStarGiftCollections(in []domain.StarGiftCollection) []tg.StarGiftCollection {
out := make([]tg.StarGiftCollection, 0, len(in))
for _, collection := range in {
out = append(out, tgStarGiftCollection(collection))
}
return out
}
func tgStarGiftCollection(in domain.StarGiftCollection) tg.StarGiftCollection {
return tg.StarGiftCollection{
CollectionID: in.CollectionID,
Title: in.Title,
GiftsCount: len(in.GiftIDs),
Hash: in.Hash,
}
}
func starGiftCollectionErr(err error) error {
switch {
case errors.Is(err, domain.ErrStarGiftNotFound),
errors.Is(err, domain.ErrStarGiftCollectibleInvalid),
errors.Is(err, domain.ErrStarGiftCollectionNotFound),
errors.Is(err, domain.ErrStarGiftCollectionsFull):
return inputRequestInvalidErr()
default:
return internalErr()
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,454 +0,0 @@
package rpc
import (
"context"
"errors"
"fmt"
"strings"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func (r *Router) starGiftUpgradePaymentForm(ctx context.Context, userID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentFormClass, error) {
saved, preview, err := r.starGiftUpgradeTarget(ctx, userID, inv.Stargift)
if err != nil {
return nil, err
}
return &tg.PaymentsPaymentFormStarGift{
FormID: starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails),
Invoice: tg.Invoice{
Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: "Star gift upgrade", Amount: preview.UpgradeStars}},
},
}, nil
}
func (r *Router) sendStarGiftUpgradeForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStarGiftUpgrade) (tg.PaymentsPaymentResultClass, error) {
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, inv.Stargift)
if err != nil {
return nil, err
}
commandKey := fmt.Sprintf("paid:%d:%d:%t", saved.ID, formID, inv.KeepOriginalDetails)
receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey)
if err != nil {
return nil, internalErr()
}
chargeStars := int64(0)
if replay {
if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != formID || receipt.RequirePrepaid ||
receipt.KeepOriginalDetails != inv.KeepOriginalDetails || receipt.ChargeStars <= 0 {
return nil, starGiftInvalidErr()
}
chargeStars = receipt.ChargeStars
} else {
preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved)
if err != nil {
return nil, err
}
wantFormID := starGiftUpgradeFormID(userID, saved.ID, preview.UpgradeStars, inv.KeepOriginalDetails)
if formID == 0 || formID != wantFormID {
return nil, starsFormAmountMismatchErr()
}
chargeStars = preview.UpgradeStars
}
result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
UserID: userID, Ref: starGiftUpgradeSavedRef(saved),
KeepOriginalDetails: inv.KeepOriginalDetails, ChargeStars: chargeStars,
FormID: formID, CommandKey: commandKey,
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx),
})
if err != nil {
return nil, starGiftUpgradeErr(err)
}
r.invalidateStarGiftOwnerProjection(saved.Owner)
updates := r.tgStarGiftUpgradeUpdates(ctx, userID, result, true)
return &tg.PaymentsPaymentResult{Updates: updates}, nil
}
func (r *Router) onPaymentsUpgradeStarGift(ctx context.Context, req *tg.PaymentsUpgradeStarGiftRequest) (tg.UpdatesClass, error) {
if req == nil || r.deps.Gifts == nil {
return nil, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, req.Stargift)
if err != nil {
return nil, err
}
commandKey := fmt.Sprintf("prepaid:%d:%t", saved.ID, req.KeepOriginalDetails)
receipt, replay, err := r.deps.Gifts.UpgradeReceipt(ctx, userID, commandKey)
if err != nil {
return nil, internalErr()
}
if replay {
if receipt.SourceSavedGiftID != saved.ID || receipt.FormID != 0 || !receipt.RequirePrepaid ||
receipt.KeepOriginalDetails != req.KeepOriginalDetails || receipt.ChargeStars != 0 {
return nil, starGiftInvalidErr()
}
} else {
if _, err := r.starGiftUpgradePreviewForSaved(ctx, saved); err != nil {
return nil, err
}
if saved.PrepaidUpgradeStars <= 0 {
return nil, starGiftInvalidErr()
}
}
result, err := r.deps.Gifts.Upgrade(ctx, domain.StarGiftUpgradeRequest{
UserID: userID, Ref: starGiftUpgradeSavedRef(saved),
KeepOriginalDetails: req.KeepOriginalDetails, RequirePrepaid: true,
CommandKey: commandKey,
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx),
OriginSessionID: sessionIDOrZero(ctx),
})
if err != nil {
return nil, starGiftUpgradeErr(err)
}
r.invalidateStarGiftOwnerProjection(saved.Owner)
return r.tgStarGiftUpgradeUpdates(ctx, userID, result, false), nil
}
func (r *Router) starGiftUpgradeTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, domain.StarGiftUpgradePreview, error) {
saved, err := r.starGiftUpgradeSavedTarget(ctx, userID, input)
if err != nil {
return domain.SavedStarGift{}, domain.StarGiftUpgradePreview{}, err
}
preview, err := r.starGiftUpgradePreviewForSaved(ctx, saved)
return saved, preview, err
}
func (r *Router) starGiftUpgradeSavedTarget(ctx context.Context, userID int64, input tg.InputSavedStarGiftClass) (domain.SavedStarGift, error) {
if r.deps.Gifts == nil {
return domain.SavedStarGift{}, notImplementedErr()
}
ref, ok, err := r.starGiftRefFromInput(ctx, userID, input)
if err != nil {
return domain.SavedStarGift{}, err
}
if !ok {
return domain.SavedStarGift{}, starGiftInvalidErr()
}
if err := r.checkStarGiftOwnerPermission(ctx, userID, ref.Owner); err != nil {
return domain.SavedStarGift{}, err
}
saved, found, err := r.deps.Gifts.GetSaved(ctx, ref)
if err != nil {
return domain.SavedStarGift{}, internalErr()
}
if !found {
return domain.SavedStarGift{}, starGiftInvalidErr()
}
return saved, nil
}
func (r *Router) starGiftUpgradePreviewForSaved(ctx context.Context, saved domain.SavedStarGift) (domain.StarGiftUpgradePreview, error) {
if saved.Converted || saved.UniqueGiftID != 0 {
return domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
}
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, saved.GiftID)
if err != nil {
return domain.StarGiftUpgradePreview{}, internalErr()
}
if !found || preview.UpgradeStars <= 0 || preview.Issued >= preview.SupplyTotal {
return domain.StarGiftUpgradePreview{}, starGiftInvalidErr()
}
return preview, nil
}
func starGiftUpgradeSavedRef(saved domain.SavedStarGift) domain.SavedStarGiftRef {
ref := domain.SavedStarGiftRef{Owner: saved.Owner}
if saved.Owner.Type == domain.PeerTypeChannel {
ref.SavedID = saved.SavedID
} else {
ref.MsgID = saved.MsgID
}
return ref
}
func (r *Router) tgStarGiftUpgradeUpdates(ctx context.Context, ownerUserID int64, result domain.StarGiftUpgradeResult, includeBalance bool) *tg.Updates {
message, event := result.Send.RecipientMessage, result.Send.RecipientEvent
if result.Send.SenderMessage.OwnerUserID == ownerUserID {
message, event = result.Send.SenderMessage, result.Send.SenderEvent
}
updates := tgPrivateMessageUpdates(event, message, 0, false,
r.usersForMessageUpdate(ctx, ownerUserID, message),
r.chatsForMessageUpdate(ctx, ownerUserID, message))
for _, edit := range result.SourceEdits {
if edit.UserID != ownerUserID {
continue
}
if update := tgOtherUpdateFromEvent(edit.Event); update != nil {
updates.Updates = append(updates.Updates, update)
if edit.Event.Date > updates.Date {
updates.Date = edit.Event.Date
}
}
}
if includeBalance {
updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: result.Balance.Balance}})
}
return updates
}
func starGiftUpgradeFormID(userID, savedGiftID, stars int64, keepOriginal bool) int64 {
id := userID*0x9e3779b1 ^ savedGiftID<<11 ^ stars<<19 ^ 0x55504752414445
if keepOriginal {
id ^= 0x4b454550
}
if id < 0 {
id = ^id
}
if id == 0 {
id = 1
}
return id
}
func starGiftUpgradeErr(err error) error {
switch {
case errors.Is(err, domain.ErrStarsInsufficient):
return starsErr(err)
case errors.Is(err, domain.ErrStarGiftNotFound),
errors.Is(err, domain.ErrStarGiftAlreadyConverted),
errors.Is(err, domain.ErrStarGiftAlreadyUpgraded),
errors.Is(err, domain.ErrStarGiftCollectibleUnavailable),
errors.Is(err, domain.ErrStarGiftCollectibleSoldOut),
errors.Is(err, domain.ErrStarGiftCollectibleInvalid):
return starGiftInvalidErr()
default:
return internalErr()
}
}
func sessionIDOrZero(ctx context.Context) int64 {
sessionID, _ := SessionIDFrom(ctx)
return sessionID
}
func (r *Router) onPaymentsGetStarGiftUpgradePreview(ctx context.Context, giftID int64) (*tg.PaymentsStarGiftUpgradePreview, error) {
if giftID <= 0 || r.deps.Gifts == nil {
return nil, starGiftInvalidErr()
}
preview, found, err := r.deps.Gifts.CollectiblePreviewSample(ctx, giftID)
if err != nil {
return nil, internalErr()
}
if !found || preview.Issued >= preview.SupplyTotal {
return nil, starGiftInvalidErr()
}
return &tg.PaymentsStarGiftUpgradePreview{
SampleAttributes: tgStarGiftPreviewAttributes(preview),
Prices: []tg.StarGiftUpgradePrice{},
NextPrices: []tg.StarGiftUpgradePrice{},
}, nil
}
func (r *Router) onPaymentsGetStarGiftUpgradeAttributes(ctx context.Context, giftID int64) (*tg.PaymentsStarGiftUpgradeAttributes, error) {
if giftID <= 0 || r.deps.Gifts == nil {
return nil, starGiftInvalidErr()
}
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, giftID)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, starGiftInvalidErr()
}
return &tg.PaymentsStarGiftUpgradeAttributes{Attributes: tgAllStarGiftAttributes(preview)}, nil
}
func (r *Router) onPaymentsGetUniqueStarGift(ctx context.Context, slug string) (*tg.PaymentsUniqueStarGift, error) {
if r.deps.Gifts == nil || strings.TrimSpace(slug) == "" {
return nil, starGiftInvalidErr()
}
viewerUserID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
unique, found, err := r.deps.Gifts.UniqueBySlug(ctx, slug)
if err != nil {
return nil, internalErr()
}
if !found {
return nil, starGiftInvalidErr()
}
out := &tg.PaymentsUniqueStarGift{
Gift: tgUniqueStarGift(unique),
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
switch unique.Owner.Type {
case domain.PeerTypeUser:
ids := []int64{unique.Owner.ID}
if unique.KeepOriginalDetails && !unique.OriginalNameHidden && unique.OriginalFromUserID != 0 && unique.OriginalFromUserID != unique.Owner.ID {
ids = append(ids, unique.OriginalFromUserID)
}
out.Users = tgUsersForViewer(viewerUserID, r.domainUsersForIDs(ctx, viewerUserID, ids))
case domain.PeerTypeChannel:
out.Chats = r.tgChatsForChannelIDs(ctx, viewerUserID, []int64{unique.Owner.ID})
}
return out, nil
}
func tgStarGiftPreviewAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass {
out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops))
for _, attribute := range preview.Models {
if attribute.Crafted {
continue
}
out = append(out, tgStarGiftAttribute(attribute))
}
for _, attribute := range preview.Patterns {
out = append(out, tgStarGiftAttribute(attribute))
}
for _, attribute := range preview.Backdrops {
out = append(out, tgStarGiftAttribute(attribute))
}
return out
}
func tgAllStarGiftAttributes(preview domain.StarGiftUpgradePreview) []tg.StarGiftAttributeClass {
out := make([]tg.StarGiftAttributeClass, 0, len(preview.Models)+len(preview.Patterns)+len(preview.Backdrops))
for _, attributes := range [][]domain.StarGiftCollectibleAttribute{preview.Models, preview.Patterns, preview.Backdrops} {
for _, attribute := range attributes {
out = append(out, tgStarGiftAttribute(attribute))
}
}
return out
}
func tgStarGiftAttribute(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeClass {
rarity := tgStarGiftAttributeRarity(attribute)
switch attribute.Kind {
case domain.StarGiftCollectibleModel:
document := tg.DocumentClass(&tg.DocumentEmpty{})
if attribute.Document != nil {
document = tgDocument(*attribute.Document)
}
return &tg.StarGiftAttributeModel{Name: attribute.Name, Document: document, Rarity: rarity, Crafted: attribute.Crafted}
case domain.StarGiftCollectiblePattern:
document := tg.DocumentClass(&tg.DocumentEmpty{})
if attribute.Document != nil {
document = tgDocument(*attribute.Document)
}
return &tg.StarGiftAttributePattern{Name: attribute.Name, Document: document, Rarity: rarity}
case domain.StarGiftCollectibleBackdrop:
return &tg.StarGiftAttributeBackdrop{
Name: attribute.Name, BackdropID: attribute.BackdropID,
CenterColor: attribute.CenterColor, EdgeColor: attribute.EdgeColor,
PatternColor: attribute.PatternColor, TextColor: attribute.TextColor, Rarity: rarity,
}
default:
return &tg.StarGiftAttributeBackdrop{Name: attribute.Name, Rarity: rarity}
}
}
func tgStarGiftAttributeRarity(attribute domain.StarGiftCollectibleAttribute) tg.StarGiftAttributeRarityClass {
switch attribute.RarityKind {
case domain.StarGiftRarityUncommon:
return &tg.StarGiftAttributeRarityUncommon{}
case domain.StarGiftRarityRare:
return &tg.StarGiftAttributeRarityRare{}
case domain.StarGiftRarityEpic:
return &tg.StarGiftAttributeRarityEpic{}
case domain.StarGiftRarityLegendary:
return &tg.StarGiftAttributeRarityLegendary{}
default:
return &tg.StarGiftAttributeRarity{Permille: attribute.RarityPermille}
}
}
func tgUniqueStarGift(unique domain.UniqueStarGift) *tg.StarGiftUnique {
attributes := []tg.StarGiftAttributeClass{
tgStarGiftAttribute(unique.Model),
tgStarGiftAttribute(unique.Pattern),
tgStarGiftAttribute(unique.Backdrop),
}
if unique.KeepOriginalDetails && unique.OriginalOwner.ID != 0 {
original := &tg.StarGiftAttributeOriginalDetails{
RecipientID: tgPeer(unique.OriginalOwner),
Date: unique.OriginalDate,
}
if unique.OriginalFromUserID != 0 && !unique.OriginalNameHidden {
original.SetSenderID(&tg.PeerUser{UserID: unique.OriginalFromUserID})
}
if unique.OriginalMessage != "" {
original.SetMessage(tg.TextWithEntities{Text: unique.OriginalMessage})
}
attributes = append(attributes, original)
}
out := &tg.StarGiftUnique{
RequirePremium: unique.RequirePremium, ResaleTonOnly: unique.ResaleTonOnly,
ThemeAvailable: unique.ThemeAvailable, Burned: unique.Burned, Crafted: unique.Crafted,
ID: unique.ID, GiftID: unique.GiftID, Title: unique.Title, Slug: unique.Slug, Num: unique.Num,
Attributes: attributes, AvailabilityIssued: unique.AvailabilityIssued, AvailabilityTotal: unique.AvailabilityTotal,
}
if unique.OwnerAddress != "" {
out.SetOwnerAddress(unique.OwnerAddress)
} else if owner := tgPeer(unique.Owner); owner != nil {
out.SetOwnerID(owner)
} else if unique.OwnerName != "" {
out.SetOwnerName(unique.OwnerName)
}
if unique.GiftAddress != "" {
out.SetGiftAddress(unique.GiftAddress)
}
if unique.ResellAmount != nil {
out.SetResellAmount([]tg.StarsAmountClass{tgStarGiftAmount(*unique.ResellAmount)})
}
if peer := tgPeer(unique.ReleasedBy); peer != nil {
out.SetReleasedBy(peer)
}
if unique.ValueAmount > 0 {
out.SetValueAmount(unique.ValueAmount)
}
if unique.ValueCurrency != "" {
out.SetValueCurrency(unique.ValueCurrency)
}
if unique.ValueUSD > 0 {
out.SetValueUsdAmount(unique.ValueUSD)
}
if peer := tgPeer(unique.ThemePeer); peer != nil {
out.SetThemePeer(peer)
}
if peer := tgPeer(unique.Host); peer != nil {
out.SetHostID(peer)
}
if unique.OfferMinStars > 0 && unique.Owner.Type == domain.PeerTypeUser {
out.SetOfferMinStars(unique.OfferMinStars)
}
if unique.CraftChancePermille > 0 {
out.SetCraftChancePermille(unique.CraftChancePermille)
}
return out
}
func tgStarGiftAmount(amount domain.StarGiftAmount) tg.StarsAmountClass {
if amount.Currency == domain.StarGiftCurrencyTON {
return &tg.StarsTonAmount{Amount: amount.Amount}
}
return &tg.StarsAmount{Amount: amount.Amount, Nanos: amount.Nanos}
}
func domainStarGiftAmount(amount tg.StarsAmountClass) (domain.StarGiftAmount, bool) {
switch value := amount.(type) {
case *tg.StarsAmount:
if value == nil {
return domain.StarGiftAmount{}, false
}
out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyStars, Amount: value.Amount, Nanos: value.Nanos}
return out, out.Valid()
case *tg.StarsTonAmount:
if value == nil {
return domain.StarGiftAmount{}, false
}
out := domain.StarGiftAmount{Currency: domain.StarGiftCurrencyTON, Amount: value.Amount}
return out, out.Valid()
default:
return domain.StarGiftAmount{}, false
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,102 +0,0 @@
package rpc
import (
"context"
"fmt"
"telesrv/internal/domain"
)
type adminUniqueStarGiftGranter interface {
GrantUnique(context.Context, domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error)
}
// AdminGrantStarGift delivers a catalog gift to a recipient peer on behalf of
// grant.SenderID without charging any Stars. It powers the admin console "Give
// gift" action: the gift is loaded from the catalog and delivered through the
// exact same path a paid send uses (messageActionStarGift service message for
// users, saved-gift + admin log for channels), only the Stars debit is skipped.
//
// SenderID must be zero or the official system account (777000). When Upgrade
// is true, the store assigns a genuine collectible directly in the same
// transaction as its service message and durable updates. The optional
// ModelAttributeID / PatternAttributeID / BackdropAttributeID pin specific
// collectible facts (0 => random; number is always sequential). Upgraded
// delivery is supported for user recipients only.
func (r *Router) AdminGrantStarGift(ctx context.Context, grant domain.AdminStarGiftGrant) error {
senderID := grant.SenderID
if senderID <= 0 {
senderID = domain.OfficialSystemUserID
}
if senderID != domain.OfficialSystemUserID {
return fmt.Errorf("gift sender must be the official system account")
}
if grant.GiftID <= 0 {
return fmt.Errorf("gift_id is required")
}
if grant.Recipient.ID <= 0 {
return fmt.Errorf("recipient is required")
}
if r.deps.Gifts == nil {
return fmt.Errorf("gifts dependency is not configured")
}
gift, ok, err := r.deps.Gifts.GiftByID(ctx, grant.GiftID)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("gift %d not found", grant.GiftID)
}
if grant.Upgrade {
return r.adminGrantUpgradedStarGift(ctx, senderID, gift, grant)
}
switch grant.Recipient.Type {
case domain.PeerTypeUser:
_, _, err = r.sendStarGiftToUser(ctx, senderID, grant.Recipient.ID, gift, grant.HideName, grant.Message, 0)
return err
case domain.PeerTypeChannel:
_, _, err = r.sendStarGiftToChannel(ctx, senderID, grant.Recipient.ID, gift, grant.HideName, grant.Message, 0)
return err
default:
return fmt.Errorf("unsupported recipient peer type %q", grant.Recipient.Type)
}
}
// adminGrantUpgradedStarGift assigns a collectible through the atomic store
// boundary, so a failure cannot leave a regular gift, partial issuance, pts or
// outbox event behind.
func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64, gift domain.StarGift, grant domain.AdminStarGiftGrant) error {
if grant.Recipient.Type != domain.PeerTypeUser {
return fmt.Errorf("upgraded gift delivery is supported for user recipients only")
}
preview, found, err := r.deps.Gifts.CollectiblePreview(ctx, gift.ID)
if err != nil {
return err
}
if !found || preview.UpgradeStars <= 0 {
return fmt.Errorf("gift %d has no published collectible upgrade", gift.ID)
}
if preview.Issued >= preview.SupplyTotal {
return fmt.Errorf("gift %d collectible supply is exhausted", gift.ID)
}
granter, ok := r.deps.Gifts.(adminUniqueStarGiftGranter)
if !ok {
return fmt.Errorf("atomic collectible grant is not configured")
}
recipientBlocked, err := r.peerBlocksUser(ctx, senderID, grant.Recipient.ID)
if err != nil {
return err
}
grant.SenderID = senderID
grant.Date = int(r.clock.Now().Unix())
grant.RecipientBlocked = recipientBlocked
grant.RecipientUnsaved, err = r.starGiftRecipientUnsaved(ctx, senderID, grant.Recipient)
if err != nil {
return err
}
if _, err := granter.GrantUnique(ctx, grant); err != nil {
return err
}
r.invalidateStarGiftOwnerProjection(grant.Recipient)
return nil
}

File diff suppressed because it is too large Load diff

View file

@ -1,262 +0,0 @@
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.StarsPurchaseForm
purchased domain.StarsPurchaseRequest
purchases int
}
func (s *starsFriendGiftRPCStore) IssueStarsPurchaseForm(_ context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
form.FormID = 70001
s.issued = form
return form, nil
}
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{
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.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},
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, PublicBaseURL: "https://links.example.test"}, Deps{
Users: appusers.NewService(users),
Stars: appstars.NewService(st, appstars.WithStartingGrant(0), appstars.WithPurchaseStore(st)),
}, zaptest.NewLogger(t), clock.System)
return r, st, buyer, recipient
}
func TestStarsFriendGiftOptionsFormAndFiatSettlement(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.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.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)
}
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("sendPaymentForm 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)
}
if st.purchases != 1 {
t.Fatalf("settlement count = %d, want one fiat submit", 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)
}
}
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.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 {
t.Fatalf("invalid request reached store: issued=%+v purchases=%d", st.issued, st.purchases)
}
}
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,
}}
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])
}
}

View file

@ -1,318 +0,0 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appstars "telesrv/internal/app/stars"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func starsRouter(t *testing.T, grant int64) *Router {
t.Helper()
svc := appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(grant))
return New(Config{}, Deps{Stars: svc}, zaptest.NewLogger(t), clock.System)
}
// getStarsStatus 首读惰性授予后返回真实余额;响应必须是合法 starsStatus
// (balance 必填 + chats/users 非 nil vector)。
func TestOnPaymentsGetStarsStatusGranted(t *testing.T) {
r := starsRouter(t, 1000)
ctx := WithUserID(context.Background(), 1000000001)
status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
t.Fatalf("getStarsStatus: %v", err)
}
amount, ok := status.Balance.(*tg.StarsAmount)
if !ok || amount.Amount != 1000 {
t.Fatalf("balance = %#v, want StarsAmount 1000", status.Balance)
}
if status.Chats == nil || status.Users == nil {
t.Fatalf("chats/users must be non-nil vectors, got chats=%v users=%v", status.Chats, status.Users)
}
// 余额是 flag 外必填字段,不能省略。
if _, hasHistory := status.GetHistory(); hasHistory {
t.Fatalf("status (not transactions) should carry no history")
}
}
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)
ctx := WithUserID(context.Background(), 1000000001)
// SetTon 同时置 flag 位+字段(gotd true-flag:GetTon 读 flag 位,手工 struct 字面量不置位)。
req := &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}
req.SetTon(true)
status, err := r.onPaymentsGetStarsStatus(ctx, req)
if err != nil {
t.Fatalf("getStarsStatus ton: %v", err)
}
if _, ok := status.Balance.(*tg.StarsTonAmount); !ok {
t.Fatalf("ton balance = %#v, want StarsTonAmount", status.Balance)
}
}
// getStarsTransactions 返回授予流水;keyset 分页末页省略 next_offset(防 DrKLO 死循环)。
func TestOnPaymentsGetStarsTransactions(t *testing.T) {
r := starsRouter(t, 1000)
ctx := WithUserID(context.Background(), 1000000001)
status, err := r.onPaymentsGetStarsTransactions(ctx, &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
t.Fatalf("getStarsTransactions: %v", err)
}
history, ok := status.GetHistory()
if !ok || len(history) != 1 {
t.Fatalf("history = %d ok=%v, want 1 grant txn", len(history), ok)
}
txn := history[0]
if amount, ok := txn.Amount.(*tg.StarsAmount); !ok || amount.Amount != 1000 {
t.Fatalf("grant txn amount = %#v, want +1000", txn.Amount)
}
// grant 走 Fragment 对手方(Peer 必填,不可 nil)。
if _, ok := txn.Peer.(*tg.StarsTransactionPeerFragment); !ok {
t.Fatalf("grant peer = %#v, want StarsTransactionPeerFragment", txn.Peer)
}
// 单页装得下 → 无 next_offset。
if off, ok := status.GetNextOffset(); ok {
t.Fatalf("single-page next_offset = %q, want absent (no infinite paging)", off)
}
}
func TestOnPaymentsGetStarsTransactionsDirections(t *testing.T) {
const userID int64 = 1000000001
svc := appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(0))
ctx := WithUserID(context.Background(), userID)
if _, err := svc.Credit(ctx, userID, 100, domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil {
t.Fatalf("credit 100: %v", err)
}
if _, err := svc.Debit(ctx, userID, 40, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
t.Fatalf("debit 40: %v", err)
}
if _, err := svc.Credit(ctx, userID, 20, domain.StarsReasonGift, domain.Peer{}, "", ""); err != nil {
t.Fatalf("credit 20: %v", err)
}
if _, err := svc.Debit(ctx, userID, 10, domain.StarsReasonReaction, domain.Peer{}, "", ""); err != nil {
t.Fatalf("debit 10: %v", err)
}
r := New(Config{}, Deps{Stars: svc}, zaptest.NewLogger(t), clock.System)
all := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}, Limit: 50}
assertRPCStarsAmounts(t, r, ctx, all, []int64{-10, 20, -40, 100})
incoming := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}, Limit: 50}
incoming.SetInbound(true)
assertRPCStarsAmounts(t, r, ctx, incoming, []int64{20, 100})
outgoing := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}, Limit: 50}
outgoing.SetOutbound(true)
assertRPCStarsAmounts(t, r, ctx, outgoing, []int64{-10, -40})
ascending := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}, Limit: 50}
ascending.SetInbound(true)
ascending.SetAscending(true)
assertRPCStarsAmounts(t, r, ctx, ascending, []int64{100, 20})
}
func TestOnPaymentsGetStarsTransactionsRejectsInvalidFilters(t *testing.T) {
r := starsRouter(t, 1000)
ctx := WithUserID(context.Background(), 1000000001)
both := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}}
both.SetInbound(true)
both.SetOutbound(true)
if _, err := r.onPaymentsGetStarsTransactions(ctx, both); err == nil {
t.Fatal("mutually exclusive inbound/outbound unexpectedly succeeded")
}
subscription := &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}}
subscription.SetSubscriptionID("subscription-1")
if _, err := r.onPaymentsGetStarsTransactions(ctx, subscription); err == nil {
t.Fatal("unsupported subscription filter unexpectedly returned the unfiltered ledger")
}
}
func assertRPCStarsAmounts(t *testing.T, r *Router, ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest, want []int64) {
t.Helper()
status, err := r.onPaymentsGetStarsTransactions(ctx, req)
if err != nil {
t.Fatalf("getStarsTransactions: %v", err)
}
history, _ := status.GetHistory()
if len(history) != len(want) {
t.Fatalf("history count = %d, want %d: %+v", len(history), len(want), history)
}
for i, amount := range want {
stars, ok := history[i].Amount.(*tg.StarsAmount)
if !ok || stars.Amount != amount {
t.Fatalf("history[%d].amount = %#v, want %d", i, history[i].Amount, amount)
}
}
}
func TestTGStarsTransactionsPaidMessage(t *testing.T) {
out := tgStarsTransactions([]domain.StarsTransaction{{
ID: 1, UserID: 42, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 50},
Amount: -10, Date: 1700002002, Reason: domain.StarsReasonPaidMessage, Title: "Paid message",
}})
if len(out) != 1 {
t.Fatalf("paid-message transactions = %d, want 1", len(out))
}
if paid, ok := out[0].GetPaidMessages(); !ok || paid != 1 {
t.Fatalf("paid_messages = %d/%v, want 1/true", paid, ok)
}
if amount, ok := out[0].Amount.(*tg.StarsAmount); !ok || amount.Amount != -10 {
t.Fatalf("paid-message amount = %#v, want -10", out[0].Amount)
}
}
// deps.Stars==nil 兜底:返回合法的空 starsStatus(余额 0),不崩。
func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), 1000000001)
status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}})
if err != nil {
t.Fatalf("nil-deps getStarsStatus: %v", err)
}
if amount, ok := status.Balance.(*tg.StarsAmount); !ok || amount.Amount != 0 {
t.Fatalf("nil-deps balance = %#v, want StarsAmount 0", status.Balance)
}
_ = domain.DefaultStarsStartingGrant
}
type channelLedgerGifts struct {
GiftsService
starsBalance int64
tonBalance int64
starsPage domain.StarsTransactionPage
tonPage domain.TonTransactionPage
}
func (s *channelLedgerGifts) ChannelStarsBalance(context.Context, int64) (int64, error) {
return s.starsBalance, nil
}
func (s *channelLedgerGifts) ChannelStarsTransactions(context.Context, int64, domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
return s.starsPage, nil
}
func (s *channelLedgerGifts) ChannelTonBalance(context.Context, int64) (int64, error) {
return s.tonBalance, nil
}
func (s *channelLedgerGifts) ChannelTonTransactions(context.Context, int64, domain.StarsTransactionQuery) (domain.TonTransactionPage, error) {
return s.tonPage, nil
}
type channelLedgerChannels struct {
ChannelsService
view domain.ChannelView
}
func (s *channelLedgerChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) {
return s.view, nil
}
func (s *channelLedgerChannels) GetChannels(context.Context, int64, []int64) ([]domain.ChannelView, error) {
return []domain.ChannelView{s.view}, nil
}
func TestPaymentsStarsLedgerUsesRequestedChannelOwner(t *testing.T) {
const viewerID, channelID int64 = 1000000001, 2000000001
view := domain.ChannelView{
Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true, CreatorUserID: viewerID},
Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive},
}
gifts := &channelLedgerGifts{
starsBalance: 20,
tonBalance: 900,
starsPage: domain.StarsTransactionPage{Balance: 20, Transactions: []domain.StarsTransaction{{
ID: 1, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, Amount: 20, Date: 10, Reason: domain.StarsReasonGift,
}}},
tonPage: domain.TonTransactionPage{Balance: 900, Transactions: []domain.TonTransaction{{
ID: 2, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2000000002}, GiftID: 9, Amount: 900, Date: 11, Reason: domain.StarsReasonGiftResale,
}}},
}
r := New(Config{}, Deps{Gifts: gifts, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), viewerID)
peer := &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash}
status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: peer})
if err != nil {
t.Fatalf("get channel stars status: %v", err)
}
if amount, ok := status.Balance.(*tg.StarsAmount); !ok || amount.Amount != 20 || len(status.Chats) != 1 {
t.Fatalf("channel stars status = %+v chats=%d", status.Balance, len(status.Chats))
}
revenue, err := r.onPaymentsGetStarsRevenueStats(ctx, &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer})
if err != nil {
t.Fatalf("get channel stars revenue: %v", err)
}
if current, ok := revenue.Status.CurrentBalance.(*tg.StarsAmount); !ok || current.Amount != 20 {
t.Fatalf("channel stars revenue current = %+v", revenue.Status.CurrentBalance)
}
if overall, ok := revenue.Status.OverallRevenue.(*tg.StarsAmount); !ok || overall.Amount != 20 || revenue.Status.WithdrawalEnabled {
t.Fatalf("channel stars revenue overall = %+v withdrawal=%v", revenue.Status.OverallRevenue, revenue.Status.WithdrawalEnabled)
}
txnReq := &tg.PaymentsGetStarsTransactionsRequest{Peer: peer, Limit: 20}
txnReq.SetTon(true)
transactions, err := r.onPaymentsGetStarsTransactions(ctx, txnReq)
if err != nil {
t.Fatalf("get channel ton transactions: %v", err)
}
history, ok := transactions.GetHistory()
if amount, amountOK := transactions.Balance.(*tg.StarsTonAmount); !amountOK || amount.Amount != 900 || !ok || len(history) != 1 || !history[0].StargiftResale {
t.Fatalf("channel ton transactions = balance=%+v history=%+v", transactions.Balance, history)
}
revenueReq := &tg.PaymentsGetStarsRevenueStatsRequest{Peer: peer}
revenueReq.SetTon(true)
tonRevenue, err := r.onPaymentsGetStarsRevenueStats(ctx, revenueReq)
if err != nil {
t.Fatalf("get channel ton revenue: %v", err)
}
if current, ok := tonRevenue.Status.CurrentBalance.(*tg.StarsTonAmount); !ok || current.Amount != 900 {
t.Fatalf("channel ton revenue current = %+v", tonRevenue.Status.CurrentBalance)
}
}
func TestPaymentsStarsLedgerRejectsNonAdminChannelReader(t *testing.T) {
const viewerID, channelID int64 = 1000000001, 2000000001
view := domain.ChannelView{
Channel: domain.Channel{ID: channelID, AccessHash: 9876, Title: "Gift Channel", Broadcast: true},
Self: domain.ChannelMember{ChannelID: channelID, UserID: viewerID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive},
}
r := New(Config{}, Deps{Gifts: &channelLedgerGifts{}, Channels: &channelLedgerChannels{view: view}}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), viewerID)
_, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: view.Channel.AccessHash}})
if err == nil {
t.Fatal("non-admin channel ledger read unexpectedly succeeded")
}
}

View file

@ -1,28 +0,0 @@
package rpc
import (
"context"
"telesrv/internal/domain"
)
// starGiftRecipientUnsaved evaluates privacyKeyStarGiftsAutoSave at the
// ownership-write boundary. The rule does not reject the gift: it decides
// whether an incoming user gift is displayed immediately (unsaved=false) or
// waits for the recipient's approval (unsaved=true).
func (r *Router) starGiftRecipientUnsaved(ctx context.Context, senderUserID int64, recipient domain.Peer) (bool, error) {
if recipient.Type != domain.PeerTypeUser || recipient.ID == 0 ||
senderUserID == 0 || senderUserID == recipient.ID || r.deps.Privacy == nil {
return false, nil
}
allowed, err := r.deps.Privacy.CanSee(
ctx,
recipient.ID,
senderUserID,
domain.PrivacyKeyStarGiftsAutoSave,
)
if err != nil {
return false, internalErr()
}
return !allowed, nil
}

View file

@ -1288,9 +1288,6 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
{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{}},
@ -1350,11 +1347,6 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
{name: "stories.getPeerMaxIDs", req: &tg.StoriesGetPeerMaxIDsRequest{ID: []tg.InputPeerClass{&tg.InputPeerSelf{}}}},
{name: "stories.getStoriesViews", req: &tg.StoriesGetStoriesViewsRequest{Peer: &tg.InputPeerSelf{}, ID: []int{1}}},
{name: "stories.getChatsToSend", req: &tg.StoriesGetChatsToSendRequest{}},
{name: "payments.getStarGiftActiveAuctions", req: &tg.PaymentsGetStarGiftActiveAuctionsRequest{}},
{name: "payments.getStarGifts", req: &tg.PaymentsGetStarGiftsRequest{}},
{name: "payments.getStarGiftCollections", req: &tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}}},
{name: "payments.getSavedStarGifts", req: &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}},
{name: "payments.getSavedStarGift", req: &tg.PaymentsGetSavedStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{}}},
{name: "payments.getStarsRevenueAdsAccountUrl", req: &tg.PaymentsGetStarsRevenueAdsAccountURLRequest{Peer: &tg.InputPeerSelf{}}},
{name: "payments.getStarsRevenueStats", req: &tg.PaymentsGetStarsRevenueStatsRequest{Ton: true, Peer: &tg.InputPeerSelf{}}},
{name: "bots.getBotRecommendations", req: &tg.BotsGetBotRecommendationsRequest{Bot: &tg.InputUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}},

View file

@ -324,18 +324,6 @@ func collectChannelMessagePeerRefs(msg domain.ChannelMessage, currentChannelID i
userIDs[id] = struct{}{}
}
}
if msg.Action.StarGift != nil {
if id := msg.Action.StarGift.FromUserID; id != 0 && !msg.Action.StarGift.NameHidden {
userIDs[id] = struct{}{}
}
if id := msg.Action.StarGift.PeerUserID; id != 0 {
userIDs[id] = struct{}{}
}
if id := msg.Action.StarGift.PeerChannelID; id != 0 && id != currentChannelID {
channelIDs[id] = struct{}{}
}
}
collectStarGiftUniquePeerRefs(msg.Action.StarGiftUnique, currentChannelID, userIDs, channelIDs)
}
if msg.Reactions != nil {
for _, reaction := range msg.Reactions.Recent {
@ -356,37 +344,6 @@ func collectServiceActionPeerRefs(media *domain.MessageMedia, currentChannelID i
addDomainPeerRef(peer, currentChannelID, userIDs, channelIDs)
}
}
if gift := action.StarGift; gift != nil {
if gift.FromUserID != 0 && !gift.NameHidden {
userIDs[gift.FromUserID] = struct{}{}
}
if gift.PeerUserID != 0 {
userIDs[gift.PeerUserID] = struct{}{}
}
if gift.PeerChannelID != 0 && gift.PeerChannelID != currentChannelID {
channelIDs[gift.PeerChannelID] = struct{}{}
}
addDomainPeerRef(gift.To, currentChannelID, userIDs, channelIDs)
}
collectStarGiftUniquePeerRefs(action.StarGiftUnique, currentChannelID, userIDs, channelIDs)
}
func collectStarGiftUniquePeerRefs(action *domain.MessageStarGiftUniqueAction, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {
if action == nil {
return
}
if action.FromUserID != 0 {
userIDs[action.FromUserID] = struct{}{}
}
addDomainPeerRef(action.Peer, currentChannelID, userIDs, channelIDs)
addDomainPeerRef(action.Gift.Owner, currentChannelID, userIDs, channelIDs)
addDomainPeerRef(action.Gift.OriginalOwner, currentChannelID, userIDs, channelIDs)
addDomainPeerRef(action.Gift.ReleasedBy, currentChannelID, userIDs, channelIDs)
addDomainPeerRef(action.Gift.ThemePeer, currentChannelID, userIDs, channelIDs)
addDomainPeerRef(action.Gift.Host, currentChannelID, userIDs, channelIDs)
if action.Gift.OriginalFromUserID != 0 && !action.Gift.OriginalNameHidden {
userIDs[action.Gift.OriginalFromUserID] = struct{}{}
}
}
func addDomainPeerRef(peer domain.Peer, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) {

View file

@ -28,96 +28,3 @@ func TestRemoveKnownChannelRefs(t *testing.T) {
}
}
}
func TestCollectMessagePeerRefsIncludesStarGiftServiceActions(t *testing.T) {
users := map[int64]struct{}{}
channels := map[int64]struct{}{}
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
FromUserID: 1001, PeerChannelID: 55,
},
},
}}, 0, users, channels)
if _, ok := users[1001]; !ok {
t.Fatalf("ordinary star-gift user refs=%v, missing sender", users)
}
if _, ok := channels[55]; !ok {
t.Fatalf("ordinary star-gift channel refs=%v", channels)
}
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{PeerUserID: 1002},
},
}}, 0, users, channels)
if _, ok := users[1002]; !ok {
t.Fatalf("ordinary star-gift recipient refs=%v", users)
}
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
FromUserID: 2001,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 56},
Gift: domain.UniqueStarGift{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 56},
OriginalFromUserID: 2002,
OriginalOwner: domain.Peer{Type: domain.PeerTypeUser, ID: 2003},
ReleasedBy: domain.Peer{Type: domain.PeerTypeUser, ID: 2004},
ThemePeer: domain.Peer{Type: domain.PeerTypeChannel, ID: 57},
Host: domain.Peer{Type: domain.PeerTypeUser, ID: 2005},
},
},
},
}}, 0, users, channels)
for _, id := range []int64{2001, 2002, 2003, 2004, 2005} {
if _, ok := users[id]; !ok {
t.Fatalf("unique star-gift user refs=%v, missing %d", users, id)
}
}
for _, id := range []int64{56, 57} {
if _, ok := channels[id]; !ok {
t.Fatalf("unique star-gift channel refs=%v, missing %d", channels, id)
}
}
}
func TestCollectMessagePeerRefsHidesStarGiftSenderDetails(t *testing.T) {
users := map[int64]struct{}{}
channels := map[int64]struct{}{}
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGift,
StarGift: &domain.MessageStarGiftAction{
FromUserID: 1001, NameHidden: true, PeerChannelID: 55,
},
},
}}, 0, users, channels)
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionStarGiftUnique,
StarGiftUnique: &domain.MessageStarGiftUniqueAction{
Gift: domain.UniqueStarGift{
OriginalFromUserID: 2001,
OriginalNameHidden: true,
},
},
},
}}, 0, users, channels)
for _, id := range []int64{1001, 2001} {
if _, ok := users[id]; ok {
t.Fatalf("hidden star-gift sender %d leaked into refs=%v", id, users)
}
}
if _, ok := channels[55]; !ok {
t.Fatalf("hidden ordinary gift lost recipient channel ref=%v", channels)
}
}

View file

@ -362,14 +362,6 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
}
full.CommonChatsCount = common.Count
}
// star gift 数量:客户端把资料页 Gifts 区段/标签页门控在 stargifts_count>0
//(DrKLO ProfileActivity:10497 / TDesktop data_user.cpp:924),不下发则收到的礼物
// 不在资料页展示。计展示在资料的礼物数(非转换、非隐藏)。
if r.deps.Gifts != nil {
if n, err := r.deps.Gifts.CountSaved(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}); err == nil && n > 0 {
full.SetStargiftsCount(n)
}
}
// 屏蔽 premium 礼物赠送:telesrv 未实现 payments.getPremiumGiftCodeOptions,
// DrKLO GiftSheet 对个人送礼时总会渲染一个「Gift Premium」区段(fillItems:918),
// premiumTiers 永远空 → 卡死成三个 flicker 占位骨架。设 disallow_premium_gifts=true
@ -389,7 +381,6 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
full.SetBirthday(tgBirthday(u.Birthday))
}
}
r.applyAccountRatingToUserFull(ctx, currentUserID, u, &full)
// 个人频道(account.updatePersonalChannel)不在此落地:它按 viewer 实时解析,作为缓存后的
// overlay 处理(applyPersonalChannelToUserFull),避免烤进 per-(viewer,target) 投影缓存以及
// build/chats 两次解析同一频道。
@ -441,50 +432,6 @@ func (r *Router) userFullPrivacyVisibility(ctx context.Context, viewerUserID, ow
return out, nil
}
// applyAccountRatingToUserFull projects gramsrv's stored composite rating through
// the rating fields official clients already render. This is a gramsrv policy
// score, not a promise that its inputs or thresholds match Telegram's service.
//
// The projection is built inside the existing per-(viewer,target) UserFull
// cache. Therefore a cache miss adds at most one primary-key read and a cache hit
// adds none. Recompute and writes remain exclusively in the bounded background
// worker/admin paths.
func (r *Router) applyAccountRatingToUserFull(ctx context.Context, viewerUserID int64, target domain.User, full *tg.UserFull) {
targetUserID := target.ID
if r.deps.AccountRatings == nil || full == nil || !domain.RatableAccount(targetUserID, target.Bot) {
return
}
rating, err := r.deps.AccountRatings.Rating(ctx, targetUserID)
if err != nil {
// Missing, disabled and temporarily unavailable projections all preserve
// the legacy wire shape instead of failing the surrounding profile read.
return
}
full.SetStarsRating(tgAccountRatingLevel(rating.LevelSnapshot()))
if viewerUserID == 0 || viewerUserID != targetUserID {
return
}
pending, ok := rating.PendingLevel()
if !ok {
return
}
full.SetStarsMyPendingRating(tgAccountRatingLevel(pending))
full.SetStarsMyPendingRatingDate(int(rating.PendingDate.Unix()))
}
// tgAccountRatingLevel maps the local level snapshot onto starsRating#1b0e4f07.
// next_level_stars remains absent at the configured maximum local level.
func tgAccountRatingLevel(in domain.AccountRatingLevel) tg.StarsRating {
out := tg.StarsRating{
Level: in.Level,
CurrentLevelStars: in.CurrentLevelStars,
Stars: in.Stars,
}
if in.HasNextLevelStars {
out.SetNextLevelStars(in.NextLevelStars)
}
return out
}
// tgBirthday 把 domain 生日转 tg.Birthday(Year 可选,0 表示不含年份)。
func tgBirthday(b domain.Birthday) tg.Birthday {