feat(stars): sync implement unified purchase flow

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

View file

@ -916,10 +916,10 @@ func run(logger *zap.Logger) error {
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
starsStore := postgres.NewStarsStore(pool)
starsGiftPurchaseStore := postgres.NewStarsGiftPurchaseStore(pool, messageStore)
starsPurchaseStore := postgres.NewStarsPurchaseStore(pool, messageStore, channelStore)
starsService := stars.NewService(starsStore,
stars.WithStartingGrant(cfg.StarsStartingGrant),
stars.WithGiftPurchaseStore(starsGiftPurchaseStore))
stars.WithPurchaseStore(starsPurchaseStore))
starGiftStore := postgres.NewStarGiftStore(pool)
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore, postgres.WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: cfg.StarGiftTransferStars, DropOriginalDetailsStars: cfg.StarGiftDropOriginalDetailsStars,

View file

@ -0,0 +1,41 @@
DROP TABLE IF EXISTS public.stars_giveaways;
DELETE FROM public.stars_purchase_commands WHERE kind <> 'gift';
DELETE FROM public.stars_purchase_forms WHERE kind <> 'gift';
ALTER TABLE public.stars_purchase_commands
DROP CONSTRAINT stars_purchase_commands_shape_check;
ALTER TABLE public.stars_purchase_commands
RENAME COLUMN balance_after TO recipient_balance_after;
ALTER TABLE public.stars_purchase_commands
DROP COLUMN spend_peer_id,
DROP COLUMN spend_peer_type,
DROP COLUMN purpose_json,
DROP COLUMN kind,
ALTER COLUMN recipient_user_id SET NOT NULL,
ADD CONSTRAINT stars_gift_purchase_commands_shape_check CHECK (
buyer_user_id > 0 AND recipient_user_id > 0 AND
buyer_user_id <> recipient_user_id AND form_id <> 0 AND
octet_length(request_fingerprint) = 32 AND stars > 0 AND amount > 0 AND
char_length(currency) = 3 AND recipient_balance_after >= 0 AND
transaction_id <> '' AND created_at > 0);
ALTER TABLE public.stars_purchase_commands
RENAME TO stars_gift_purchase_commands;
ALTER TABLE public.stars_purchase_forms
DROP CONSTRAINT stars_purchase_forms_shape_check,
DROP COLUMN spend_peer_id,
DROP COLUMN spend_peer_type,
DROP COLUMN purpose_json,
DROP COLUMN kind,
ALTER COLUMN recipient_user_id SET NOT NULL,
ADD CONSTRAINT stars_gift_purchase_forms_shape_check CHECK (
buyer_user_id > 0 AND recipient_user_id > 0 AND
buyer_user_id <> recipient_user_id AND form_id <> 0 AND
stars > 0 AND amount > 0 AND char_length(currency) = 3 AND
currency = upper(currency) AND issued_at > 0 AND
expires_at = issued_at + 600);
ALTER INDEX public.stars_purchase_forms_expiry_idx
RENAME TO stars_gift_purchase_forms_expiry_idx;
ALTER TABLE public.stars_purchase_forms
RENAME TO stars_gift_purchase_forms;

View file

@ -0,0 +1,81 @@
ALTER TABLE public.stars_gift_purchase_forms
RENAME TO stars_purchase_forms;
ALTER INDEX public.stars_gift_purchase_forms_expiry_idx
RENAME TO stars_purchase_forms_expiry_idx;
ALTER TABLE public.stars_purchase_forms
DROP CONSTRAINT stars_gift_purchase_forms_shape_check,
ADD COLUMN kind text NOT NULL DEFAULT 'gift',
ADD COLUMN spend_peer_type text,
ADD COLUMN spend_peer_id bigint,
ADD COLUMN purpose_json jsonb NOT NULL DEFAULT '{}'::jsonb,
ALTER COLUMN recipient_user_id DROP NOT NULL;
ALTER TABLE public.stars_purchase_forms
ALTER COLUMN kind DROP DEFAULT,
ADD CONSTRAINT stars_purchase_forms_shape_check CHECK (
kind IN ('topup', 'gift', 'giveaway') AND buyer_user_id > 0 AND form_id <> 0 AND
((kind IN ('topup', 'giveaway') AND recipient_user_id IS NULL) OR
(kind = 'gift' AND recipient_user_id > 0 AND buyer_user_id <> recipient_user_id)) AND
((spend_peer_type IS NULL AND spend_peer_id IS NULL) OR
(kind = 'topup' AND spend_peer_type IN ('user', 'channel') AND spend_peer_id > 0)) AND
((kind IN ('topup', 'gift') AND purpose_json = '{}'::jsonb) OR
(kind = 'giveaway' AND jsonb_typeof(purpose_json) = 'object' AND purpose_json <> '{}'::jsonb)) AND
stars > 0 AND amount > 0 AND char_length(currency) = 3 AND
currency = upper(currency) AND issued_at > 0 AND
expires_at = issued_at + 600);
ALTER TABLE public.stars_gift_purchase_commands
RENAME TO stars_purchase_commands;
ALTER TABLE public.stars_purchase_commands
DROP CONSTRAINT stars_gift_purchase_commands_shape_check,
ADD COLUMN kind text NOT NULL DEFAULT 'gift',
ADD COLUMN spend_peer_type text,
ADD COLUMN spend_peer_id bigint,
ADD COLUMN purpose_json jsonb NOT NULL DEFAULT '{}'::jsonb,
ALTER COLUMN recipient_user_id DROP NOT NULL;
ALTER TABLE public.stars_purchase_commands
RENAME COLUMN recipient_balance_after TO balance_after;
ALTER TABLE public.stars_purchase_commands
ALTER COLUMN kind DROP DEFAULT,
ADD CONSTRAINT stars_purchase_commands_shape_check CHECK (
kind IN ('topup', 'gift', 'giveaway') AND buyer_user_id > 0 AND form_id <> 0 AND
((kind IN ('topup', 'giveaway') AND recipient_user_id IS NULL) OR
(kind = 'gift' AND recipient_user_id > 0 AND buyer_user_id <> recipient_user_id)) AND
((spend_peer_type IS NULL AND spend_peer_id IS NULL) OR
(kind = 'topup' AND spend_peer_type IN ('user', 'channel') AND spend_peer_id > 0)) AND
((kind IN ('topup', 'gift') AND purpose_json = '{}'::jsonb) OR
(kind = 'giveaway' AND jsonb_typeof(purpose_json) = 'object' AND purpose_json <> '{}'::jsonb)) AND
octet_length(request_fingerprint) = 32 AND stars > 0 AND amount > 0 AND
char_length(currency) = 3 AND balance_after >= 0 AND
transaction_id <> '' AND created_at > 0);
CREATE TABLE public.stars_giveaways (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
buyer_user_id bigint NOT NULL,
form_id bigint NOT NULL,
channel_id bigint NOT NULL,
launch_message_id integer NOT NULL,
random_id bigint NOT NULL,
stars bigint NOT NULL,
users integer NOT NULL,
per_user_stars bigint NOT NULL,
yearly_boosts integer NOT NULL,
until_date integer NOT NULL,
purpose_json jsonb NOT NULL,
state text NOT NULL DEFAULT 'active',
created_at integer NOT NULL,
CONSTRAINT stars_giveaways_form_fk FOREIGN KEY (buyer_user_id, form_id)
REFERENCES public.stars_purchase_forms(buyer_user_id, form_id) ON DELETE RESTRICT,
CONSTRAINT stars_giveaways_form_unique UNIQUE (buyer_user_id, form_id),
CONSTRAINT stars_giveaways_random_unique UNIQUE (buyer_user_id, channel_id, random_id),
CONSTRAINT stars_giveaways_launch_unique UNIQUE (channel_id, launch_message_id),
CONSTRAINT stars_giveaways_shape_check CHECK (
buyer_user_id > 0 AND form_id <> 0 AND channel_id > 0 AND launch_message_id > 0 AND
random_id <> 0 AND stars > 0 AND users > 0 AND per_user_stars > 0 AND
users::bigint * per_user_stars = stars AND yearly_boosts >= 0 AND until_date > created_at AND
jsonb_typeof(purpose_json) = 'object' AND purpose_json <> '{}'::jsonb AND
state IN ('active', 'completed', 'cancelled') AND created_at > 0)
);
CREATE INDEX stars_giveaways_channel_state_until_idx
ON public.stars_giveaways(channel_id, state, until_date, id);

View file

@ -8,6 +8,7 @@ import (
"strconv"
"sync"
compatandroid "telesrv/internal/compat/android"
"telesrv/internal/domain"
"telesrv/internal/seed/catalog"
"telesrv/internal/store"
@ -55,10 +56,10 @@ const tdesktopClient = "tdesktop"
//
// WebK directly calls Array.some on fragment_prefixes while rendering user profiles,
// so this compatibility key must always remain an array, even when it is empty.
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"bot_verification_description_length_limit":70,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"giveaway_gifts_purchase_available":true,"giveaway_boosts_per_premium":4,"giveaway_countries_max":10,"giveaway_add_peers_max":10,"giveaway_period_max":604800,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"bot_verification_description_length_limit":70,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
const tdesktopNoForwardsAppConfig = `,"no_forwards_request_expire_period":86400`
const defaultAppConfigHash = 26 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
const defaultAppConfigHash = 27 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
// Service 提供客户端启动配置与国家区号目录。
//
@ -117,15 +118,16 @@ func defaultAppConfig(mapboxToken string) domain.AppConfig {
}
func defaultAppConfigJSON(mapboxToken string) []byte {
androidInvoiceBilling := `,"premium_playmarket_direct_currency_list":` + compatandroid.DirectInvoiceCurrenciesJSON()
if mapboxToken == "" {
return []byte(tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + `}`)
return []byte(tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + androidInvoiceBilling + `}`)
}
token, err := json.Marshal(mapboxToken)
if err != nil {
return []byte(tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + `}`)
return []byte(tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + androidInvoiceBilling + `}`)
}
tokenJSON := string(token)
return []byte(tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + `,"tdesktop_config_map":{"maps":` + tokenJSON + `,"geo":` + tokenJSON + `,"bmaps":` + tokenJSON + `,"bgeo":` + tokenJSON + `}}`)
return []byte(tdesktopDefaultAppConfigBase + tdesktopNoForwardsAppConfig + androidInvoiceBilling + `,"tdesktop_config_map":{"maps":` + tokenJSON + `,"geo":` + tokenJSON + `,"bmaps":` + tokenJSON + `,"bgeo":` + tokenJSON + `}}`)
}
func defaultAppConfigHashFor(mapboxToken string) int {

View file

@ -42,6 +42,13 @@ func TestAppConfigPremiumKeys(t *testing.T) {
if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked {
t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"])
}
if available, ok := decoded["giveaway_gifts_purchase_available"].(bool); !ok || !available {
t.Fatalf("giveaway_gifts_purchase_available = %v, want true", decoded["giveaway_gifts_purchase_available"])
}
directCurrencies, ok := decoded["premium_playmarket_direct_currency_list"].([]any)
if !ok || len(directCurrencies) == 0 || !containsJSONCurrency(directCurrencies, "USD") {
t.Fatalf("premium_playmarket_direct_currency_list = %#v, want non-empty list containing USD", decoded["premium_playmarket_direct_currency_list"])
}
if posting, ok := decoded["rich_message_posting"].(string); !ok || posting != "enabled" {
t.Fatalf("rich_message_posting = %v, want enabled (TDesktop 富文本编辑入口默认打开)", decoded["rich_message_posting"])
}
@ -50,6 +57,10 @@ func TestAppConfigPremiumKeys(t *testing.T) {
t.Fatalf("fragment_prefixes = %#v, want [\"888\"]", decoded["fragment_prefixes"])
}
wantNumbers := map[string]float64{
"giveaway_boosts_per_premium": 4,
"giveaway_countries_max": 10,
"giveaway_add_peers_max": 10,
"giveaway_period_max": 604800,
"reactions_user_max_default": 1,
"reactions_user_max_premium": 3,
"boosts_channel_level_max": 100,
@ -99,6 +110,15 @@ func TestAppConfigPremiumKeys(t *testing.T) {
}
}
func containsJSONCurrency(values []any, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) {
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, 0)
if err != nil || notModified {

View file

@ -13,10 +13,10 @@ import (
// Service 是 Stars 账本应用服务。
type Service struct {
store store.StarsStore
giftStore store.StarsGiftPurchaseStore
grantAmount int64
now func() time.Time
store store.StarsStore
purchaseStore store.StarsPurchaseStore
grantAmount int64
now func() time.Time
}
// Option 配置 Service。
@ -27,9 +27,9 @@ func WithStartingGrant(amount int64) Option {
return func(s *Service) { s.grantAmount = amount }
}
// WithGiftPurchaseStore enables the atomic fiat Stars-gift checkout aggregate.
func WithGiftPurchaseStore(st store.StarsGiftPurchaseStore) Option {
return func(s *Service) { s.giftStore = st }
// WithPurchaseStore enables the atomic fiat Stars checkout aggregate.
func WithPurchaseStore(st store.StarsPurchaseStore) Option {
return func(s *Service) { s.purchaseStore = st }
}
// WithClock 注入时钟(测试用)。
@ -96,24 +96,55 @@ func (s *Service) ListTransactions(ctx context.Context, userID int64, query doma
return s.store.ListTransactions(ctx, userID, query)
}
// IssueGiftPurchaseForm persists a short-lived, exact checkout intent.
func (s *Service) IssueGiftPurchaseForm(ctx context.Context, form domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) {
if s.giftStore == nil || form.BuyerUserID <= 0 || form.RecipientUserID <= 0 ||
form.BuyerUserID == form.RecipientUserID || form.Stars <= 0 || form.Amount <= 0 ||
form.Currency == "" || form.IssuedAt <= 0 || form.ExpiresAt != form.IssuedAt+600 {
return domain.StarsGiftPurchaseForm{}, domain.ErrStarsGiftFormInvalid
// IssuePurchaseForm persists a short-lived, exact checkout intent.
func (s *Service) IssuePurchaseForm(ctx context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
if s.purchaseStore == nil || !validPurchaseForm(form) {
return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid
}
return s.giftStore.IssueStarsGiftPurchaseForm(ctx, form)
return s.purchaseStore.IssueStarsPurchaseForm(ctx, form)
}
// PurchaseGift settles one exact persisted form. Package validation remains at
// Purchase settles one exact persisted form. Package validation remains at
// the RPC boundary as well, while the store revalidates the persisted tuple
// under lock before performing any write.
func (s *Service) PurchaseGift(ctx context.Context, req domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) {
if s.giftStore == nil || req.FormID == 0 || req.BuyerUserID <= 0 || req.RecipientUserID <= 0 ||
req.BuyerUserID == req.RecipientUserID || req.Stars <= 0 || req.Amount <= 0 ||
req.Currency == "" || req.Date <= 0 {
return domain.StarsGiftPurchaseResult{}, domain.ErrStarsGiftFormInvalid
func (s *Service) Purchase(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
if s.purchaseStore == nil || req.FormID == 0 || req.Date <= 0 || !validPurchaseCommand(req.StarsPurchaseForm) {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
return s.purchaseStore.PurchaseStars(ctx, req)
}
// GetGiveawayInfo resolves one launch card from the same aggregate that
// persisted it. date is supplied by the RPC clock for deterministic tests.
func (s *Service) GetGiveawayInfo(ctx context.Context, viewerUserID, channelID int64, messageID, date int) (domain.StarsGiveawayInfo, error) {
reader, ok := s.purchaseStore.(store.StarsGiveawayStore)
if !ok || viewerUserID <= 0 || channelID <= 0 || messageID <= 0 || date <= 0 {
return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid
}
return reader.GetStarsGiveawayInfo(ctx, viewerUserID, channelID, messageID, date)
}
func validPurchaseForm(form domain.StarsPurchaseForm) bool {
return validPurchaseCommand(form) && form.IssuedAt > 0 && form.ExpiresAt == form.IssuedAt+600
}
func validPurchaseCommand(form domain.StarsPurchaseForm) bool {
if !form.Kind.Valid() || form.BuyerUserID <= 0 || form.Stars <= 0 || form.Amount <= 0 || form.Currency == "" {
return false
}
switch form.Kind {
case domain.StarsPurchaseTopup:
return form.Giveaway == nil && form.RecipientUserID == 0 && ((form.SpendPurposePeer == domain.Peer{}) ||
((form.SpendPurposePeer.Type == domain.PeerTypeUser || form.SpendPurposePeer.Type == domain.PeerTypeChannel) && form.SpendPurposePeer.ID > 0))
case domain.StarsPurchaseGift:
return form.Giveaway == nil && form.RecipientUserID > 0 && form.BuyerUserID != form.RecipientUserID && form.SpendPurposePeer == (domain.Peer{})
case domain.StarsPurchaseGiveaway:
g := form.Giveaway
return form.RecipientUserID == 0 && form.SpendPurposePeer == (domain.Peer{}) && g != nil &&
g.BoostPeer.Type == domain.PeerTypeChannel && g.BoostPeer.ID > 0 && g.RandomID != 0 &&
g.UntilDate > 0 && g.Users > 0 && g.PerUserStars > 0 &&
int64(g.Users) <= form.Stars/g.PerUserStars && int64(g.Users)*g.PerUserStars == form.Stars
default:
return false
}
return s.giftStore.PurchaseStarsGift(ctx, req)
}

View file

@ -0,0 +1,24 @@
package android
import "encoding/json"
// DirectInvoiceCurrencies are the Google Play billing currencies for which
// DrKLO's official client must use Telegram's invoice flow. telesrv does not
// publish or verify Google Play products, so this prevents Stars options with
// no store_product from entering the Play Billing branch once it is ready.
var directInvoiceCurrencies = []string{
"AED", "AUD", "BRL", "CAD", "CHF", "CLP", "CNY", "COP", "CZK", "DKK",
"EGP", "EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "JPY", "KRW",
"KZT", "MXN", "MYR", "NGN", "NOK", "NZD", "PEN", "PHP", "PKR", "PLN",
"QAR", "RON", "RUB", "SAR", "SEK", "SGD", "THB", "TRY", "TWD", "UAH",
"USD", "VND", "ZAR",
}
func DirectInvoiceCurrencies() []string {
return append([]string(nil), directInvoiceCurrencies...)
}
func DirectInvoiceCurrenciesJSON() string {
body, _ := json.Marshal(directInvoiceCurrencies)
return string(body)
}

View file

@ -0,0 +1,30 @@
package android
import (
"encoding/json"
"testing"
)
func TestDirectInvoiceCurrenciesAreStableAndContainUSD(t *testing.T) {
values := DirectInvoiceCurrencies()
if len(values) == 0 {
t.Fatal("direct invoice currency list is empty")
}
foundUSD := false
for _, value := range values {
if value == "USD" {
foundUSD = true
}
}
if !foundUSD {
t.Fatalf("direct invoice currencies = %v, want USD", values)
}
values[0] = "MUTATED"
if DirectInvoiceCurrencies()[0] == "MUTATED" {
t.Fatal("DirectInvoiceCurrencies returned mutable package storage")
}
var decoded []string
if err := json.Unmarshal([]byte(DirectInvoiceCurrenciesJSON()), &decoded); err != nil || len(decoded) != len(values) {
t.Fatalf("currency JSON = %q decoded=%v err=%v", DirectInvoiceCurrenciesJSON(), decoded, err)
}
}

View file

@ -3,11 +3,12 @@ package tdesktop
import (
"github.com/iamxvbaba/td/tg"
compatandroid "telesrv/internal/compat/android"
"telesrv/internal/seed/catalog"
)
const (
appConfigHash = 17 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。
appConfigHash = 18 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。
countriesListHash = 1
timezonesListHash = 1
)
@ -38,8 +39,15 @@ func readMarkAppConfig(mapboxToken string) *tg.JSONObject {
// premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口
// (详见 app/help/service.go 主配置注释)。这里是无 HelpService 时的最小回退。
{Key: "premium_purchase_blocked", Value: &tg.JSONBool{Value: false}},
{Key: "stars_purchase_blocked", Value: &tg.JSONBool{Value: false}},
// stargifts_blocked=falseDrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格。
{Key: "stargifts_blocked", Value: &tg.JSONBool{Value: false}},
{Key: "giveaway_gifts_purchase_available", Value: &tg.JSONBool{Value: true}},
{Key: "giveaway_boosts_per_premium", Value: &tg.JSONNumber{Value: 4}},
{Key: "giveaway_countries_max", Value: &tg.JSONNumber{Value: 10}},
{Key: "giveaway_add_peers_max", Value: &tg.JSONNumber{Value: 10}},
{Key: "giveaway_period_max", Value: &tg.JSONNumber{Value: 604800}},
{Key: "premium_playmarket_direct_currency_list", Value: tgStringArray(compatandroid.DirectInvoiceCurrencies())},
{Key: "reactions_user_max_premium", Value: &tg.JSONNumber{Value: 3}},
// DrKLO 频道自定义 reaction 编辑页用它作为可选 reaction 数量上限。
{Key: "boosts_channel_level_max", Value: &tg.JSONNumber{Value: 100}},
@ -88,6 +96,14 @@ func readMarkAppConfig(mapboxToken string) *tg.JSONObject {
return &tg.JSONObject{Value: values}
}
func tgStringArray(values []string) *tg.JSONArray {
result := &tg.JSONArray{Value: make([]tg.JSONValueClass, 0, len(values))}
for _, value := range values {
result.Value = append(result.Value, &tg.JSONString{Value: value})
}
return result
}
// fallbackTimezones 是 catalog 未 seed 时的内置最小时区集。
var fallbackTimezones = []tg.Timezone{
{ID: "Etc/UTC", Name: "UTC", UtcOffset: 0},

View file

@ -43,6 +43,8 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) {
values := make(map[string]float64)
strings := make(map[string]string)
arrays := make(map[string]*tg.JSONArray)
bools := make(map[string]bool)
boolSeen := make(map[string]bool)
if object, ok := got.Config.(*tg.JSONObject); ok && object != nil {
for _, entry := range object.Value {
if number, ok := entry.Value.(*tg.JSONNumber); ok {
@ -54,12 +56,20 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) {
if array, ok := entry.Value.(*tg.JSONArray); ok {
arrays[entry.Key] = array
}
if boolean, ok := entry.Value.(*tg.JSONBool); ok {
bools[entry.Key] = boolean.Value
boolSeen[entry.Key] = true
}
}
}
want := map[string]float64{
"stories_stealth_future_period": 1500,
"stories_stealth_past_period": 300,
"stories_stealth_cooldown_period": 10800,
"giveaway_boosts_per_premium": 4,
"giveaway_countries_max": 10,
"giveaway_add_peers_max": 10,
"giveaway_period_max": 604800,
}
for key, expected := range want {
if values[key] != expected {
@ -69,6 +79,10 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) {
if strings["rich_message_posting"] != "enabled" {
t.Fatalf("AppConfig[rich_message_posting] = %q, want enabled", strings["rich_message_posting"])
}
if !boolSeen["stars_purchase_blocked"] || bools["stars_purchase_blocked"] ||
!boolSeen["giveaway_gifts_purchase_available"] || !bools["giveaway_gifts_purchase_available"] {
t.Fatalf("AppConfig purchase flags = stars_blocked:%v giveaway_available:%v", bools["stars_purchase_blocked"], bools["giveaway_gifts_purchase_available"])
}
fragmentPrefixes := arrays["fragment_prefixes"]
if fragmentPrefixes == nil || len(fragmentPrefixes.Value) != 1 {
t.Fatalf("AppConfig[fragment_prefixes] = %#v, want one-element array", fragmentPrefixes)
@ -77,6 +91,10 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) {
if !ok || prefix.Value != "888" {
t.Fatalf("AppConfig[fragment_prefixes][0] = %#v, want \"888\"", fragmentPrefixes.Value[0])
}
directCurrencies := arrays["premium_playmarket_direct_currency_list"]
if directCurrencies == nil || !tgJSONArrayContainsString(directCurrencies, "USD") {
t.Fatalf("AppConfig[premium_playmarket_direct_currency_list] = %#v, want USD", directCurrencies)
}
if _, ok := AppConfig(got.Hash).(*tg.HelpAppConfigNotModified); !ok {
t.Fatalf("AppConfig(hash) = %#v, want notModified", AppConfig(got.Hash))
}
@ -85,6 +103,15 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) {
}
}
func tgJSONArrayContainsString(array *tg.JSONArray, want string) bool {
for _, value := range array.Value {
if text, ok := value.(*tg.JSONString); ok && text.Value == want {
return true
}
}
return false
}
func TestFallbackAppConfigOmitsMapboxToken(t *testing.T) {
got, ok := AppConfig(0).(*tg.HelpAppConfig)
if !ok {

View file

@ -418,8 +418,23 @@ const (
MessageMediaKindTodo MessageMediaKind = "todo"
MessageMediaKindStory MessageMediaKind = "story"
MessageMediaKindWebPage MessageMediaKind = "web_page"
MessageMediaKindGiveaway MessageMediaKind = "giveaway"
)
// MessageGiveaway is the immutable launch-card snapshot shown in the boost
// peer. Channels contains the boost peer first followed by any additional
// channels users must join. A results card is a separate lifecycle message.
type MessageGiveaway struct {
OnlyNewSubscribers bool `json:"only_new_subscribers,omitempty"`
WinnersAreVisible bool `json:"winners_are_visible,omitempty"`
Channels []int64 `json:"channels"`
CountriesISO2 []string `json:"countries_iso2,omitempty"`
PrizeDescription string `json:"prize_description,omitempty"`
Quantity int `json:"quantity"`
Stars int64 `json:"stars"`
UntilDate int `json:"until_date"`
}
// MessageTodoItem 是清单中的一项id 为列表内唯一正整数,客户端分配)。
type MessageTodoItem struct {
ID int `json:"id"`
@ -765,6 +780,7 @@ type MessageMedia struct {
Todo *MessageTodo `json:"todo,omitempty"`
Story *MessageStory `json:"story,omitempty"`
WebPage *MessageWebPage `json:"web_page,omitempty"`
Giveaway *MessageGiveaway `json:"giveaway,omitempty"`
Spoiler bool `json:"spoiler,omitempty"`
TTLSeconds int `json:"ttl_seconds,omitempty"`
Nopremium bool `json:"nopremium,omitempty"`

View file

@ -17,36 +17,84 @@ type StarsBalance struct {
Granted bool // 起始授予是否已应用(惰性首读授予的幂等守卫)
}
// StarsGiftPurchaseForm binds one short-lived fiat Stars-gift checkout to its
// authenticated buyer, recipient and exact server-advertised package. The
// client may echo these values but cannot use a form for a different intent.
type StarsGiftPurchaseForm struct {
FormID int64
BuyerUserID int64
RecipientUserID int64
Stars int64
Currency string
Amount int64
IssuedAt int
ExpiresAt int
// StarsPurchaseKind identifies the balance owner affected by a fiat Stars
// checkout. It is persisted with the form so a client cannot reinterpret a
// self top-up as a friend gift (or vice versa) when submitting the form.
type StarsPurchaseKind string
const (
StarsPurchaseTopup StarsPurchaseKind = "topup"
StarsPurchaseGift StarsPurchaseKind = "gift"
StarsPurchaseGiveaway StarsPurchaseKind = "giveaway"
)
func (k StarsPurchaseKind) Valid() bool {
return k == StarsPurchaseTopup || k == StarsPurchaseGift || k == StarsPurchaseGiveaway
}
// StarsGiftPurchaseRequest is the immutable settlement command carried by
// inputInvoiceStars(inputStorePaymentStarsGift).
type StarsGiftPurchaseRequest struct {
StarsGiftPurchaseForm
// StarsGiveawayPurchase is the complete immutable purpose behind one direct
// fiat Stars giveaway checkout. The launch purchase persists this shape; the
// eventual winner draw is a separate lifecycle transition.
type StarsGiveawayPurchase struct {
BoostPeer Peer `json:"boost_peer"`
AdditionalPeers []Peer `json:"additional_peers,omitempty"`
CountriesISO2 []string `json:"countries_iso2,omitempty"`
PrizeDescription string `json:"prize_description,omitempty"`
RandomID int64 `json:"random_id"`
UntilDate int `json:"until_date"`
Users int `json:"users"`
PerUserStars int64 `json:"per_user_stars"`
YearlyBoosts int `json:"yearly_boosts"`
OnlyNewSubscribers bool `json:"only_new_subscribers,omitempty"`
WinnersAreVisible bool `json:"winners_are_visible,omitempty"`
}
// StarsPurchaseForm binds one short-lived fiat Stars checkout to its
// authenticated buyer, purpose and exact server-advertised package. Recipient
// is zero for a self top-up and mandatory for a friend gift.
type StarsPurchaseForm struct {
FormID int64
Kind StarsPurchaseKind
BuyerUserID int64
RecipientUserID int64
SpendPurposePeer Peer
Giveaway *StarsGiveawayPurchase
Stars int64
Currency string
Amount int64
IssuedAt int
ExpiresAt int
}
// StarsPurchaseRequest is the immutable settlement command carried by
// inputInvoiceStars. Android and TDesktop sendPaymentForm both resolve to this
// command after the ordinary fiat checkout has produced provider credentials.
type StarsPurchaseRequest struct {
StarsPurchaseForm
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
// StarsGiftPurchaseResult is the atomically committed recipient credit and
// bilateral service-message receipt. Duplicate means an exact form replay.
type StarsGiftPurchaseResult struct {
RecipientBalance StarsBalance
Send SendPrivateTextResult
TransactionID string
Duplicate bool
// StarsPurchaseResult is the atomically committed credit and, for a friend
// gift, bilateral service-message receipt. Duplicate means an exact form replay.
type StarsPurchaseResult struct {
Balance StarsBalance
Send SendPrivateTextResult
ChannelSend SendChannelMessageResult
TransactionID string
Duplicate bool
}
// StarsGiveawayInfo is the viewer-specific state of one durable launch card.
// Winner selection/results are intentionally outside the purchase aggregate.
type StarsGiveawayInfo struct {
StartDate int
Participating bool
PreparingResults bool
JoinedTooEarlyDate int
AdminDisallowedChatID int64
DisallowedCountry string
}
// StarsTransactionReason 标记一条流水的语义(投影到 tg.StarsTransaction 的标志位/标题)。
@ -184,10 +232,10 @@ var (
ErrStarsInvalidAmount = errors.New("stars: invalid amount")
// ErrStarsTransactionQueryInvalid 表示内部构造了不可能的流水方向。
ErrStarsTransactionQueryInvalid = errors.New("stars: invalid transaction query")
// ErrStarsGiftFormInvalid covers a missing/cross-account/mutated form.
ErrStarsGiftFormInvalid = errors.New("stars: gift form invalid")
// ErrStarsGiftFormExpired is returned before any settlement write.
ErrStarsGiftFormExpired = errors.New("stars: gift form expired")
// ErrStarsPurchaseFormInvalid covers a missing/cross-account/mutated form.
ErrStarsPurchaseFormInvalid = errors.New("stars: purchase form invalid")
// ErrStarsPurchaseFormExpired is returned before any settlement write.
ErrStarsPurchaseFormExpired = errors.New("stars: purchase form expired")
// ErrStarsGiftUnavailable covers a recipient that cannot receive the gift.
ErrStarsGiftUnavailable = errors.New("stars: gift unavailable")
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,6 +12,18 @@ import (
)
func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) {
return s.sendChannelMessageWithHooks(ctx, req, channelSendTxHooks{})
}
type channelSendTxHooks struct {
before func(context.Context, pgx.Tx, *domain.SendChannelMessageRequest) error
after func(context.Context, pgx.Tx, domain.SendChannelMessageResult) error
}
// sendChannelMessageWithHooks lets a tightly coupled domain command join the
// channel message/event/PTS transaction. It is deliberately package-private:
// ordinary callers must use SendChannelMessage and may not inject SQL work.
func (s *ChannelStore) sendChannelMessageWithHooks(ctx context.Context, req domain.SendChannelMessageRequest, hooks channelSendTxHooks) (domain.SendChannelMessageResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero()) {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
@ -27,7 +39,7 @@ func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendCh
}
var lastErr error
for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ {
res, err := s.sendChannelMessageOnce(ctx, req, requestFingerprint)
res, err := s.sendChannelMessageOnce(ctx, req, requestFingerprint, hooks)
if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil {
return res, err
}
@ -36,7 +48,7 @@ func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendCh
return domain.SendChannelMessageResult{}, lastErr
}
func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest, requestFingerprint []byte) (domain.SendChannelMessageResult, error) {
func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest, requestFingerprint []byte, hooks channelSendTxHooks) (domain.SendChannelMessageResult, error) {
if req.RandomID != 0 && !req.IdempotencyPreflighted {
if dup, found, err := s.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
ChannelID: req.ChannelID,
@ -125,6 +137,11 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
p := *req.SendAs
sendAs = &p
}
if hooks.before != nil {
if err := hooks.before(ctx, tx, &req); err != nil {
return domain.SendChannelMessageResult{}, err
}
}
msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.ChannelID)
if err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate channel message id: %w", err)
@ -135,7 +152,7 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
}
var discussion *domain.SendChannelDiscussionResult
var discussionRef *domain.ChannelDiscussionRef
if channel.Broadcast && channel.LinkedChatID != 0 {
if channel.Broadcast && channel.LinkedChatID != 0 && req.Action == nil {
linked, err := getChannelByID(ctx, tx, channel.LinkedChatID)
if err == nil && !linked.Deleted && linked.Megagroup {
discussionMsgID, err := s.msgIDs.NextChannelMessageID(ctx, linked.ID)
@ -330,6 +347,16 @@ WHERE channel_id = $1 AND user_id = $2 AND unread_mark`, req.ChannelID, req.User
return domain.SendChannelMessageResult{}, err
}
}
txResult := domain.SendChannelMessageResult{
Channel: channel, Message: msg, Event: event, Discussion: discussion,
MentionUserIDs: append([]int64(nil), req.MentionUserIDs...),
SkipDeliveryUserIDs: append([]int64(nil), req.SkipDeliveryUserIDs...),
}
if hooks.after != nil {
if err := hooks.after(ctx, tx, txResult); err != nil {
return domain.SendChannelMessageResult{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("commit send channel: %w", err)
}
@ -342,7 +369,8 @@ WHERE channel_id = $1 AND user_id = $2 AND unread_mark`, req.ChannelID, req.User
discussion.Recipients, _ = s.ListActiveChannelMemberIDs(ctx, req.UserID, discussion.Channel.ID, 0)
}
}
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: recipients, Discussion: discussion, MentionUserIDs: append([]int64(nil), req.MentionUserIDs...), SkipDeliveryUserIDs: append([]int64(nil), req.SkipDeliveryUserIDs...)}, nil
txResult.Recipients = recipients
return txResult, nil
}
func channelDeliverySkipSet(ids []int64) map[int64]struct{} {

View file

@ -6,69 +6,96 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// StarsGiftPurchaseStore commits a fiat Stars gift as one aggregate with the
// private service message. No external provider is contacted by this local
// development checkout; form binding and settlement idempotency are still
// production-shaped so retries cannot mint twice.
type StarsGiftPurchaseStore struct {
// StarsPurchaseStore commits fiat Stars top-ups, friend gifts and giveaway
// launches. No external
// provider is contacted by this local development checkout; form binding and
// settlement idempotency are still production-shaped so retries cannot mint twice.
type StarsPurchaseStore struct {
db sqlcgen.DBTX
messages *MessageStore
channels *ChannelStore
}
func NewStarsGiftPurchaseStore(db sqlcgen.DBTX, messages *MessageStore) *StarsGiftPurchaseStore {
return &StarsGiftPurchaseStore{db: db, messages: messages}
func NewStarsPurchaseStore(db sqlcgen.DBTX, messages *MessageStore, channels ...*ChannelStore) *StarsPurchaseStore {
var channelStore *ChannelStore
if len(channels) > 0 {
channelStore = channels[0]
}
return &StarsPurchaseStore{db: db, messages: messages, channels: channelStore}
}
func (s *StarsGiftPurchaseStore) IssueStarsGiftPurchaseForm(ctx context.Context, form domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error) {
if s == nil || s.db == nil || form.BuyerUserID <= 0 || form.RecipientUserID <= 0 ||
form.BuyerUserID == form.RecipientUserID || form.Stars <= 0 || form.Amount <= 0 ||
len(form.Currency) != 3 || form.IssuedAt <= 0 || form.ExpiresAt != form.IssuedAt+600 {
return domain.StarsGiftPurchaseForm{}, domain.ErrStarsGiftFormInvalid
func (s *StarsPurchaseStore) IssueStarsPurchaseForm(ctx context.Context, form domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error) {
if s == nil || s.db == nil || !validStarsPurchaseForm(form) {
return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid
}
purposeJSON, err := starsPurchasePurposeJSON(form)
if err != nil {
return domain.StarsPurchaseForm{}, domain.ErrStarsPurchaseFormInvalid
}
for attempt := 0; attempt < 8; attempt++ {
formID, err := newStarsGiftFormID()
formID, err := newStarsPurchaseFormID()
if err != nil {
return domain.StarsGiftPurchaseForm{}, fmt.Errorf("generate stars gift form id: %w", err)
return domain.StarsPurchaseForm{}, fmt.Errorf("generate stars purchase form id: %w", err)
}
tag, err := s.db.Exec(ctx, `
INSERT INTO stars_gift_purchase_forms
(buyer_user_id,form_id,recipient_user_id,stars,currency,amount,issued_at,expires_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT DO NOTHING`, form.BuyerUserID, formID, form.RecipientUserID, form.Stars,
form.Currency, form.Amount, form.IssuedAt, form.ExpiresAt)
INSERT INTO stars_purchase_forms
(buyer_user_id,form_id,kind,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,issued_at,expires_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT DO NOTHING`, form.BuyerUserID, formID, string(form.Kind), starsPurchaseRecipientValue(form.RecipientUserID),
starsPurchasePeerTypeValue(form.SpendPurposePeer), starsPurchasePeerIDValue(form.SpendPurposePeer),
purposeJSON, form.Stars, form.Currency, form.Amount, form.IssuedAt, form.ExpiresAt)
if err != nil {
return domain.StarsGiftPurchaseForm{}, fmt.Errorf("insert stars gift form: %w", err)
return domain.StarsPurchaseForm{}, fmt.Errorf("insert stars purchase form: %w", err)
}
if tag.RowsAffected() == 1 {
form.FormID = formID
return form, nil
}
}
return domain.StarsGiftPurchaseForm{}, domain.ErrStarsGiftUnavailable
return domain.StarsPurchaseForm{}, domain.ErrStarsGiftUnavailable
}
var errStarsGiftPurchaseReplay = errors.New("stars gift purchase replay")
var errStarsPurchaseReplay = errors.New("stars purchase replay")
func (s *StarsGiftPurchaseStore) PurchaseStarsGift(ctx context.Context, req domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error) {
if s == nil || s.db == nil || s.messages == nil || req.FormID == 0 ||
req.BuyerUserID <= 0 || req.RecipientUserID <= 0 || req.BuyerUserID == req.RecipientUserID ||
req.Stars <= 0 || req.Amount <= 0 || len(req.Currency) != 3 || req.Date <= 0 {
return domain.StarsGiftPurchaseResult{}, domain.ErrStarsGiftFormInvalid
func (s *StarsPurchaseStore) PurchaseStars(ctx context.Context, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error) {
if s == nil || s.db == nil || req.FormID == 0 || req.Date <= 0 || !validStarsPurchaseCommand(req.StarsPurchaseForm) {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
fingerprint := starsGiftPurchaseFingerprint(req)
if replay, found, err := s.loadStarsGiftPurchaseReplay(ctx, req, fingerprint); err != nil || found {
fingerprint := starsPurchaseFingerprint(req)
if replay, found, err := s.loadStarsPurchaseReplay(ctx, req, fingerprint); err != nil || found {
return replay, err
}
// A committed command is replayable after both the checkout and giveaway
// deadlines: the provider may retry a successful submission after losing the
// response, and a terminal campaign must not turn that exact retry into a
// different outcome. The deadline only gates a first settlement.
if req.Kind == domain.StarsPurchaseGiveaway && req.Giveaway.UntilDate <= req.Date {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormExpired
}
switch req.Kind {
case domain.StarsPurchaseTopup:
return s.purchaseStarsTopup(ctx, req, fingerprint)
case domain.StarsPurchaseGiveaway:
return s.purchaseStarsGiveaway(ctx, req, fingerprint)
}
if s.messages == nil {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
transactionID := fmt.Sprintf("stars-gift:%d:%d", req.BuyerUserID, req.FormID)
randomID := lifecycleCommandRandomID("stars-fiat-gift", req.BuyerUserID, req.FormID)
@ -83,16 +110,16 @@ func (s *StarsGiftPurchaseStore) PurchaseStarsGift(ctx context.Context, req doma
OriginUserID: req.BuyerUserID, OriginAuthKeyID: req.OriginAuthKeyID,
OriginSessionID: req.OriginSessionID, IdempotencyFingerprint: fingerprint[:],
}
result := domain.StarsGiftPurchaseResult{TransactionID: transactionID}
result := domain.StarsPurchaseResult{TransactionID: transactionID}
hooks := privateSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, send *domain.SendPrivateTextRequest) error {
if err := validateStarsGiftPurchaseForm(ctx, tx, req, true); err != nil {
if err := validateStarsPurchaseForm(ctx, tx, req, true); err != nil {
return err
}
if found, err := starsGiftPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil {
if found, err := starsPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil {
return err
} else if found {
return errStarsGiftPurchaseReplay
return errStarsPurchaseReplay
}
balance := domain.StarsBalance{UserID: req.RecipientUserID}
if err := tx.QueryRow(ctx, `
@ -107,21 +134,20 @@ RETURNING balance,granted`, req.RecipientUserID, req.Stars).Scan(&balance.Balanc
"Stars gift", fmt.Sprintf("%d Stars", req.Stars)); err != nil {
return err
}
result.RecipientBalance = balance
result.Balance = balance
if send.Media == nil || send.Media.ServiceAction == nil || send.Media.ServiceAction.GiftStars == nil {
return domain.ErrStarsGiftFormInvalid
return domain.ErrStarsPurchaseFormInvalid
}
send.Media.ServiceAction.GiftStars.BalanceAfter = balance.Balance
return nil
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendPrivateTextResult) error {
_, err := tx.Exec(ctx, `
INSERT INTO stars_gift_purchase_commands
(buyer_user_id,form_id,request_fingerprint,recipient_user_id,stars,currency,amount,
recipient_balance_after,transaction_id,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.BuyerUserID, req.FormID, fingerprint[:],
req.RecipientUserID, req.Stars, req.Currency, req.Amount,
result.RecipientBalance.Balance, transactionID, req.Date)
INSERT INTO stars_purchase_commands
(buyer_user_id,form_id,kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,
balance_after,transaction_id,created_at)
VALUES($1,$2,$3,$4,$5,NULL,NULL,'{}'::jsonb,$6,$7,$8,$9,$10,$11)`, req.BuyerUserID, req.FormID, string(req.Kind), fingerprint[:],
req.RecipientUserID, req.Stars, req.Currency, req.Amount, result.Balance.Balance, transactionID, req.Date)
if err != nil {
return fmt.Errorf("insert stars gift purchase command: %w", err)
}
@ -131,61 +157,233 @@ VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.BuyerUserID, req.FormID, fingerprin
}
sent, err := s.messages.sendPrivateTextWithHooks(ctx, messageReq, hooks)
if err != nil {
if errors.Is(err, errStarsGiftPurchaseReplay) {
if replay, found, replayErr := s.loadStarsGiftPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
if errors.Is(err, errStarsPurchaseReplay) {
if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarsGiftPurchaseResult{}, err
return domain.StarsPurchaseResult{}, err
}
result.Send = sent
return result, nil
}
func validateStarsGiftPurchaseForm(ctx context.Context, db sqlcgen.DBTX, req domain.StarsGiftPurchaseRequest, lock bool) error {
query := `SELECT recipient_user_id,stars,currency,amount,issued_at,expires_at
FROM stars_gift_purchase_forms WHERE buyer_user_id=$1 AND form_id=$2`
func (s *StarsPurchaseStore) purchaseStarsTopup(ctx context.Context, req domain.StarsPurchaseRequest, fingerprint [32]byte) (domain.StarsPurchaseResult, error) {
transactionID := fmt.Sprintf("stars-topup:%d:%d", req.BuyerUserID, req.FormID)
result := domain.StarsPurchaseResult{TransactionID: transactionID}
err := withTx(ctx, s.db, "settle stars topup", func(tx pgx.Tx) error {
if err := validateStarsPurchaseForm(ctx, tx, req, true); err != nil {
return err
}
if found, err := starsPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil {
return err
} else if found {
return errStarsPurchaseReplay
}
result.Balance = domain.StarsBalance{UserID: req.BuyerUserID}
if err := tx.QueryRow(ctx, `
INSERT INTO stars_balances (user_id,balance,updated_at) VALUES($1,$2,now())
ON CONFLICT (user_id) DO UPDATE
SET balance=stars_balances.balance+EXCLUDED.balance, updated_at=now()
RETURNING balance,granted`, req.BuyerUserID, req.Stars).Scan(&result.Balance.Balance, &result.Balance.Granted); err != nil {
return fmt.Errorf("credit stars topup buyer: %w", err)
}
if err := insertStarsTxn(ctx, tx, req.BuyerUserID, req.Stars, domain.StarsReasonTopup,
req.SpendPurposePeer, req.Date, "Stars top-up", "telesrv dev purchase"); err != nil {
return err
}
_, err := tx.Exec(ctx, `
INSERT INTO stars_purchase_commands
(buyer_user_id,form_id,kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,
balance_after,transaction_id,created_at)
VALUES($1,$2,$3,$4,NULL,$5,$6,'{}'::jsonb,$7,$8,$9,$10,$11,$12)`, req.BuyerUserID, req.FormID, string(req.Kind), fingerprint[:],
starsPurchasePeerTypeValue(req.SpendPurposePeer), starsPurchasePeerIDValue(req.SpendPurposePeer),
req.Stars, req.Currency, req.Amount, result.Balance.Balance, transactionID, req.Date)
if err != nil {
return fmt.Errorf("insert stars topup command: %w", err)
}
return nil
})
if errors.Is(err, errStarsPurchaseReplay) {
if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
return replay, replayErr
}
}
if err != nil {
return domain.StarsPurchaseResult{}, err
}
return result, nil
}
func (s *StarsPurchaseStore) purchaseStarsGiveaway(ctx context.Context, req domain.StarsPurchaseRequest, fingerprint [32]byte) (domain.StarsPurchaseResult, error) {
if s.channels == nil || req.Giveaway == nil {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
purposeJSON, err := starsPurchasePurposeJSON(req.StarsPurchaseForm)
if err != nil {
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
giveaway := *req.Giveaway
transactionID := fmt.Sprintf("stars-giveaway:%d:%d", req.BuyerUserID, req.FormID)
result := domain.StarsPurchaseResult{TransactionID: transactionID}
sendReq := domain.SendChannelMessageRequest{
UserID: req.BuyerUserID, ChannelID: giveaway.BoostPeer.ID,
RandomID: giveaway.RandomID, Date: req.Date,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindGiveaway, Giveaway: &domain.MessageGiveaway{
OnlyNewSubscribers: giveaway.OnlyNewSubscribers, WinnersAreVisible: giveaway.WinnersAreVisible,
Channels: starsGiveawayChannelIDs(giveaway), CountriesISO2: append([]string(nil), giveaway.CountriesISO2...),
PrizeDescription: giveaway.PrizeDescription, Quantity: giveaway.Users, Stars: req.Stars, UntilDate: giveaway.UntilDate,
}},
IdempotencyFingerprint: fingerprint[:],
}
hooks := channelSendTxHooks{
before: func(ctx context.Context, tx pgx.Tx, _ *domain.SendChannelMessageRequest) error {
if err := validateStarsPurchaseForm(ctx, tx, req, true); err != nil {
return err
}
if found, err := starsPurchaseCommandExists(ctx, tx, req.BuyerUserID, req.FormID); err != nil {
return err
} else if found {
return errStarsPurchaseReplay
}
return nil
},
after: func(ctx context.Context, tx pgx.Tx, sent domain.SendChannelMessageResult) error {
if sent.Message.ID <= 0 || sent.Event.Pts <= 0 || sent.Event.PtsCount != 1 {
return fmt.Errorf("settle stars giveaway: invalid channel send receipt")
}
if _, err := tx.Exec(ctx, `
INSERT INTO stars_giveaways
(buyer_user_id,form_id,channel_id,launch_message_id,random_id,stars,users,per_user_stars,yearly_boosts,
until_date,purpose_json,state,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'active',$12)`,
req.BuyerUserID, req.FormID, giveaway.BoostPeer.ID, sent.Message.ID, giveaway.RandomID,
req.Stars, giveaway.Users, giveaway.PerUserStars, giveaway.YearlyBoosts, giveaway.UntilDate, purposeJSON, req.Date); err != nil {
return fmt.Errorf("insert stars giveaway campaign: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO stars_purchase_commands
(buyer_user_id,form_id,kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,
balance_after,transaction_id,created_at)
VALUES($1,$2,$3,$4,NULL,NULL,NULL,$5,$6,$7,$8,0,$9,$10)`,
req.BuyerUserID, req.FormID, string(req.Kind), fingerprint[:], purposeJSON,
req.Stars, req.Currency, req.Amount, transactionID, req.Date); err != nil {
return fmt.Errorf("insert stars giveaway purchase command: %w", err)
}
result.ChannelSend = sent
return nil
},
}
sent, err := s.channels.sendChannelMessageWithHooks(ctx, sendReq, hooks)
if err != nil {
if errors.Is(err, errStarsPurchaseReplay) {
if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
return replay, replayErr
}
}
return domain.StarsPurchaseResult{}, err
}
if sent.Duplicate {
if replay, found, replayErr := s.loadStarsPurchaseReplay(ctx, req, fingerprint); replayErr != nil || found {
return replay, replayErr
}
return domain.StarsPurchaseResult{}, domain.ErrStarsPurchaseFormInvalid
}
result.ChannelSend = sent
return result, nil
}
func starsGiveawayChannelIDs(giveaway domain.StarsGiveawayPurchase) []int64 {
ids := make([]int64, 0, 1+len(giveaway.AdditionalPeers))
ids = append(ids, giveaway.BoostPeer.ID)
for _, peer := range giveaway.AdditionalPeers {
ids = append(ids, peer.ID)
}
return ids
}
func validateStarsPurchaseForm(ctx context.Context, db sqlcgen.DBTX, req domain.StarsPurchaseRequest, lock bool) error {
query := `SELECT kind,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,issued_at,expires_at
FROM stars_purchase_forms WHERE buyer_user_id=$1 AND form_id=$2`
if lock {
query += ` FOR UPDATE`
}
var recipientID, stars, amount int64
var kind string
var recipientID pgtype.Int8
var spendPeerType pgtype.Text
var spendPeerID pgtype.Int8
var purposeJSON []byte
var stars, amount int64
var currency string
var issuedAt, expiresAt int
err := db.QueryRow(ctx, query, req.BuyerUserID, req.FormID).
Scan(&recipientID, &stars, &currency, &amount, &issuedAt, &expiresAt)
Scan(&kind, &recipientID, &spendPeerType, &spendPeerID, &purposeJSON, &stars, &currency, &amount, &issuedAt, &expiresAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrStarsGiftFormInvalid
return domain.ErrStarsPurchaseFormInvalid
}
if err != nil {
return fmt.Errorf("load stars gift form: %w", err)
}
if req.Date >= expiresAt {
return domain.ErrStarsGiftFormExpired
return domain.ErrStarsPurchaseFormExpired
}
if issuedAt <= 0 || recipientID != req.RecipientUserID || stars != req.Stars ||
currency != req.Currency || amount != req.Amount {
return domain.ErrStarsGiftFormInvalid
if issuedAt <= 0 || kind != string(req.Kind) || nullableStarsRecipient(recipientID) != req.RecipientUserID ||
nullableStarsPeer(spendPeerType, spendPeerID) != req.SpendPurposePeer || stars != req.Stars ||
currency != req.Currency || amount != req.Amount || !sameStarsPurchasePurpose(purposeJSON, req.StarsPurchaseForm) {
return domain.ErrStarsPurchaseFormInvalid
}
return nil
}
func (s *StarsGiftPurchaseStore) loadStarsGiftPurchaseReplay(ctx context.Context, req domain.StarsGiftPurchaseRequest, fingerprint [32]byte) (domain.StarsGiftPurchaseResult, bool, error) {
var recipientID, stars, amount, balance int64
var currency, transactionID string
var storedFingerprint []byte
func (s *StarsPurchaseStore) loadStarsPurchaseReplay(ctx context.Context, req domain.StarsPurchaseRequest, fingerprint [32]byte) (domain.StarsPurchaseResult, bool, error) {
var recipientID pgtype.Int8
var spendPeerType pgtype.Text
var spendPeerID pgtype.Int8
var stars, amount, balance int64
var kind, currency, transactionID string
var storedFingerprint, purposeJSON []byte
err := s.db.QueryRow(ctx, `
SELECT request_fingerprint,recipient_user_id,stars,currency,amount,recipient_balance_after,transaction_id
FROM stars_gift_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2`, req.BuyerUserID, req.FormID).
Scan(&storedFingerprint, &recipientID, &stars, &currency, &amount, &balance, &transactionID)
SELECT kind,request_fingerprint,recipient_user_id,spend_peer_type,spend_peer_id,purpose_json,stars,currency,amount,balance_after,transaction_id
FROM stars_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2`, req.BuyerUserID, req.FormID).
Scan(&kind, &storedFingerprint, &recipientID, &spendPeerType, &spendPeerID, &purposeJSON, &stars, &currency, &amount, &balance, &transactionID)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarsGiftPurchaseResult{}, false, nil
return domain.StarsPurchaseResult{}, false, nil
}
if err != nil {
return domain.StarsGiftPurchaseResult{}, false, fmt.Errorf("load stars gift purchase replay: %w", err)
return domain.StarsPurchaseResult{}, false, fmt.Errorf("load stars purchase replay: %w", err)
}
if !bytes.Equal(storedFingerprint, fingerprint[:]) || recipientID != req.RecipientUserID ||
stars != req.Stars || currency != req.Currency || amount != req.Amount || transactionID == "" {
return domain.StarsGiftPurchaseResult{}, false, domain.ErrStarsGiftFormInvalid
if kind != string(req.Kind) || !bytes.Equal(storedFingerprint, fingerprint[:]) || nullableStarsRecipient(recipientID) != req.RecipientUserID ||
nullableStarsPeer(spendPeerType, spendPeerID) != req.SpendPurposePeer ||
stars != req.Stars || currency != req.Currency || amount != req.Amount || transactionID == "" ||
!sameStarsPurchasePurpose(purposeJSON, req.StarsPurchaseForm) {
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
}
result := domain.StarsPurchaseResult{
Balance: domain.StarsBalance{UserID: req.BuyerUserID, Balance: balance},
TransactionID: transactionID, Duplicate: true,
}
if req.Kind == domain.StarsPurchaseTopup {
return result, true, nil
}
if req.Kind == domain.StarsPurchaseGiveaway {
if s.channels == nil || req.Giveaway == nil {
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
}
sent, found, err := s.channels.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
ChannelID: req.Giveaway.BoostPeer.ID, SenderUserID: req.BuyerUserID,
RandomID: req.Giveaway.RandomID, IdempotencyFingerprint: fingerprint[:],
})
if err != nil {
return domain.StarsPurchaseResult{}, false, err
}
if !found {
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
}
result.ChannelSend = sent
return result, true, nil
}
if s.messages == nil {
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
}
sent, found, err := s.messages.LookupPrivateSendReplay(ctx, domain.PrivateSendReplayRequest{
SenderUserID: req.BuyerUserID, RecipientUserID: req.RecipientUserID,
@ -193,32 +391,33 @@ FROM stars_gift_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2`, req.Bu
IdempotencyFingerprint: fingerprint[:],
})
if err != nil {
return domain.StarsGiftPurchaseResult{}, false, err
return domain.StarsPurchaseResult{}, false, err
}
if !found {
return domain.StarsGiftPurchaseResult{}, false, domain.ErrStarsGiftFormInvalid
return domain.StarsPurchaseResult{}, false, domain.ErrStarsPurchaseFormInvalid
}
return domain.StarsGiftPurchaseResult{
RecipientBalance: domain.StarsBalance{UserID: req.RecipientUserID, Balance: balance},
Send: sent, TransactionID: transactionID, Duplicate: true,
}, true, nil
result.Balance.UserID = req.RecipientUserID
result.Send = sent
return result, true, nil
}
func starsGiftPurchaseCommandExists(ctx context.Context, db sqlcgen.DBTX, buyerUserID, formID int64) (bool, error) {
func starsPurchaseCommandExists(ctx context.Context, db sqlcgen.DBTX, buyerUserID, formID int64) (bool, error) {
var exists bool
if err := db.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM stars_gift_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2)`, buyerUserID, formID).Scan(&exists); err != nil {
return false, fmt.Errorf("check stars gift purchase command: %w", err)
SELECT 1 FROM stars_purchase_commands WHERE buyer_user_id=$1 AND form_id=$2)`, buyerUserID, formID).Scan(&exists); err != nil {
return false, fmt.Errorf("check stars purchase command: %w", err)
}
return exists, nil
}
func starsGiftPurchaseFingerprint(req domain.StarsGiftPurchaseRequest) [32]byte {
return sha256.Sum256([]byte(fmt.Sprintf("telesrv:stars-fiat-gift:v1:%d:%d:%d:%s:%d:%d",
req.BuyerUserID, req.RecipientUserID, req.Stars, req.Currency, req.Amount, req.FormID)))
func starsPurchaseFingerprint(req domain.StarsPurchaseRequest) [32]byte {
purposeJSON, _ := starsPurchasePurposeJSON(req.StarsPurchaseForm)
return sha256.Sum256([]byte(fmt.Sprintf("telesrv:stars-fiat-purchase:v2:%s:%d:%d:%s:%d:%d:%s:%d:%d:%s",
req.Kind, req.BuyerUserID, req.RecipientUserID, req.SpendPurposePeer.Type, req.SpendPurposePeer.ID,
req.Stars, req.Currency, req.Amount, req.FormID, purposeJSON)))
}
func newStarsGiftFormID() (int64, error) {
func newStarsPurchaseFormID() (int64, error) {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
return 0, err
@ -230,4 +429,200 @@ func newStarsGiftFormID() (int64, error) {
return id, nil
}
var _ store.StarsGiftPurchaseStore = (*StarsGiftPurchaseStore)(nil)
func validStarsPurchaseForm(form domain.StarsPurchaseForm) bool {
if !validStarsPurchaseCommand(form) || form.IssuedAt <= 0 || form.ExpiresAt != form.IssuedAt+600 {
return false
}
return form.Kind != domain.StarsPurchaseGiveaway ||
(form.Giveaway.UntilDate > form.IssuedAt && form.Giveaway.UntilDate <= form.IssuedAt+7*24*60*60)
}
func validStarsPurchaseCommand(form domain.StarsPurchaseForm) bool {
if !form.Kind.Valid() || form.BuyerUserID <= 0 || form.Stars <= 0 || form.Amount <= 0 || len(form.Currency) != 3 {
return false
}
validSpendPeer := (form.SpendPurposePeer == domain.Peer{}) ||
((form.SpendPurposePeer.Type == domain.PeerTypeUser || form.SpendPurposePeer.Type == domain.PeerTypeChannel) && form.SpendPurposePeer.ID > 0)
switch form.Kind {
case domain.StarsPurchaseTopup:
return form.RecipientUserID == 0 && validSpendPeer && form.Giveaway == nil
case domain.StarsPurchaseGift:
return form.RecipientUserID > 0 && form.RecipientUserID != form.BuyerUserID && form.SpendPurposePeer == (domain.Peer{}) && form.Giveaway == nil
case domain.StarsPurchaseGiveaway:
return form.RecipientUserID == 0 && form.SpendPurposePeer == (domain.Peer{}) && validStarsGiveawayPurchase(form.Giveaway, form.Stars)
default:
return false
}
}
func validStarsGiveawayPurchase(g *domain.StarsGiveawayPurchase, stars int64) bool {
if g == nil || g.BoostPeer.Type != domain.PeerTypeChannel || g.BoostPeer.ID <= 0 || g.RandomID == 0 ||
g.UntilDate <= 0 || g.Users <= 0 || g.PerUserStars <= 0 || g.YearlyBoosts < 0 ||
int64(g.Users) > math.MaxInt64/g.PerUserStars || int64(g.Users)*g.PerUserStars != stars ||
len(g.AdditionalPeers) > 10 || len(g.CountriesISO2) > 10 || utf8.RuneCountInString(g.PrizeDescription) > 128 {
return false
}
seenPeers := map[int64]struct{}{g.BoostPeer.ID: struct{}{}}
for _, peer := range g.AdditionalPeers {
if peer.Type != domain.PeerTypeChannel || peer.ID <= 0 {
return false
}
if _, exists := seenPeers[peer.ID]; exists {
return false
}
seenPeers[peer.ID] = struct{}{}
}
seenCountries := make(map[string]struct{}, len(g.CountriesISO2))
for _, country := range g.CountriesISO2 {
if len(country) != 2 || country != strings.ToUpper(country) || country[0] < 'A' || country[0] > 'Z' || country[1] < 'A' || country[1] > 'Z' {
return false
}
if _, exists := seenCountries[country]; exists {
return false
}
seenCountries[country] = struct{}{}
}
return true
}
func starsPurchasePurposeJSON(form domain.StarsPurchaseForm) ([]byte, error) {
if form.Kind != domain.StarsPurchaseGiveaway {
return []byte(`{}`), nil
}
if form.Giveaway == nil {
return nil, domain.ErrStarsPurchaseFormInvalid
}
return json.Marshal(form.Giveaway)
}
func sameStarsPurchasePurpose(stored []byte, form domain.StarsPurchaseForm) bool {
want, err := starsPurchasePurposeJSON(form)
if err != nil {
return false
}
if form.Kind != domain.StarsPurchaseGiveaway {
var value map[string]any
return json.Unmarshal(stored, &value) == nil && len(value) == 0
}
var decoded domain.StarsGiveawayPurchase
if err := json.Unmarshal(stored, &decoded); err != nil {
return false
}
got, err := json.Marshal(&decoded)
return err == nil && bytes.Equal(got, want)
}
func starsPurchaseRecipientValue(recipientUserID int64) any {
if recipientUserID == 0 {
return nil
}
return recipientUserID
}
func nullableStarsRecipient(value pgtype.Int8) int64 {
if !value.Valid {
return 0
}
return value.Int64
}
func starsPurchasePeerTypeValue(peer domain.Peer) any {
if peer == (domain.Peer{}) {
return nil
}
return string(peer.Type)
}
func starsPurchasePeerIDValue(peer domain.Peer) any {
if peer == (domain.Peer{}) {
return nil
}
return peer.ID
}
func nullableStarsPeer(peerType pgtype.Text, peerID pgtype.Int8) domain.Peer {
if !peerType.Valid || !peerID.Valid {
return domain.Peer{}
}
return domain.Peer{Type: domain.PeerType(peerType.String), ID: peerID.Int64}
}
func (s *StarsPurchaseStore) GetStarsGiveawayInfo(ctx context.Context, viewerUserID, channelID int64, messageID, date int) (domain.StarsGiveawayInfo, error) {
if s == nil || s.db == nil || viewerUserID <= 0 || channelID <= 0 || messageID <= 0 || date <= 0 {
return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid
}
var purposeJSON []byte
var state string
var startDate, untilDate int
err := s.db.QueryRow(ctx, `
SELECT purpose_json,state,created_at,until_date
FROM stars_giveaways WHERE channel_id=$1 AND launch_message_id=$2`, channelID, messageID).
Scan(&purposeJSON, &state, &startDate, &untilDate)
if errors.Is(err, pgx.ErrNoRows) {
return domain.StarsGiveawayInfo{}, domain.ErrMessageIDInvalid
}
if err != nil {
return domain.StarsGiveawayInfo{}, fmt.Errorf("load stars giveaway info: %w", err)
}
var purpose domain.StarsGiveawayPurchase
if err := json.Unmarshal(purposeJSON, &purpose); err != nil || purpose.BoostPeer.ID != channelID {
return domain.StarsGiveawayInfo{}, domain.ErrStarsPurchaseFormInvalid
}
info := domain.StarsGiveawayInfo{StartDate: startDate}
if state == "cancelled" {
return info, nil
}
if state != "active" || date >= untilDate {
info.PreparingResults = true
return info, nil
}
channels := starsGiveawayChannelIDs(purpose)
for _, requiredChannelID := range channels {
var role, status string
var joinedAt int
err := s.db.QueryRow(ctx, `
SELECT role,status,joined_at FROM channel_members WHERE channel_id=$1 AND user_id=$2`, requiredChannelID, viewerUserID).
Scan(&role, &status, &joinedAt)
if errors.Is(err, pgx.ErrNoRows) {
return info, nil
}
if err != nil {
return domain.StarsGiveawayInfo{}, fmt.Errorf("load giveaway participant membership: %w", err)
}
if status != string(domain.ChannelMemberActive) {
return info, nil
}
if role == string(domain.ChannelRoleCreator) || role == string(domain.ChannelRoleAdmin) {
info.AdminDisallowedChatID = requiredChannelID
return info, nil
}
if purpose.OnlyNewSubscribers && joinedAt > 0 && joinedAt <= startDate {
info.JoinedTooEarlyDate = joinedAt
return info, nil
}
}
if len(purpose.CountriesISO2) > 0 {
var country string
if err := s.db.QueryRow(ctx, `
SELECT COALESCE((SELECT cc.iso2 FROM country_codes cc WHERE cc.country_code=u.country_code ORDER BY cc.id LIMIT 1),'')
FROM users u WHERE u.id=$1`, viewerUserID).Scan(&country); err != nil {
return domain.StarsGiveawayInfo{}, fmt.Errorf("load giveaway participant country: %w", err)
}
allowed := false
for _, candidate := range purpose.CountriesISO2 {
if candidate == country {
allowed = true
break
}
}
if !allowed {
info.DisallowedCountry = country
return info, nil
}
}
info.Participating = true
return info, nil
}
var _ store.StarsPurchaseStore = (*StarsPurchaseStore)(nil)
var _ store.StarsGiveawayStore = (*StarsPurchaseStore)(nil)

View file

@ -8,6 +8,43 @@ import (
"telesrv/internal/domain"
)
type starsPurchaseAttempt struct {
result domain.StarsPurchaseResult
err error
}
func purchaseStarsTwiceConcurrently(t *testing.T, ctx context.Context, store *StarsPurchaseStore, req domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, domain.StarsPurchaseResult) {
t.Helper()
start := make(chan struct{})
attempts := make(chan starsPurchaseAttempt, 2)
for range 2 {
go func() {
<-start
result, err := store.PurchaseStars(ctx, req)
attempts <- starsPurchaseAttempt{result: result, err: err}
}()
}
close(start)
var first, replay domain.StarsPurchaseResult
firstCount, replayCount := 0, 0
for range 2 {
attempt := <-attempts
if attempt.err != nil {
t.Fatalf("concurrent Stars purchase: %v", attempt.err)
}
if attempt.result.Duplicate {
replay, replayCount = attempt.result, replayCount+1
} else {
first, firstCount = attempt.result, firstCount+1
}
}
if firstCount != 1 || replayCount != 1 {
t.Fatalf("concurrent Stars purchase first/replay counts = %d/%d, want 1/1", firstCount, replayCount)
}
return first, replay
}
func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
@ -22,17 +59,17 @@ func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T)
t.Fatalf("create recipient: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM stars_gift_purchase_commands WHERE buyer_user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_gift_purchase_forms WHERE buyer_user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_commands WHERE buyer_user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_forms WHERE buyer_user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_transactions WHERE user_id=$1", recipient.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_balances WHERE user_id=$1", recipient.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{buyer.ID, recipient.ID})
})
messages := NewMessageStore(pool)
store := NewStarsGiftPurchaseStore(pool, messages)
issued, err := store.IssueStarsGiftPurchaseForm(ctx, domain.StarsGiftPurchaseForm{
BuyerUserID: buyer.ID, RecipientUserID: recipient.ID,
store := NewStarsPurchaseStore(pool, messages)
issued, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGift, BuyerUserID: buyer.ID, RecipientUserID: recipient.ID,
Stars: 2500, Currency: "USD", Amount: 199,
IssuedAt: 1_700_000_000, ExpiresAt: 1_700_000_600,
})
@ -41,18 +78,15 @@ func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T)
}
var origin [8]byte
origin[0] = 9
req := domain.StarsGiftPurchaseRequest{
StarsGiftPurchaseForm: domain.StarsGiftPurchaseForm{
FormID: issued.FormID, BuyerUserID: buyer.ID, RecipientUserID: recipient.ID,
req := domain.StarsPurchaseRequest{
StarsPurchaseForm: domain.StarsPurchaseForm{
FormID: issued.FormID, Kind: domain.StarsPurchaseGift, BuyerUserID: buyer.ID, RecipientUserID: recipient.ID,
Stars: 2500, Currency: "USD", Amount: 199,
},
Date: 1_700_000_100, OriginAuthKeyID: origin, OriginSessionID: 77,
}
first, err := store.PurchaseStarsGift(ctx, req)
if err != nil {
t.Fatalf("purchase: %v", err)
}
if first.Duplicate || first.RecipientBalance.Balance != 2500 || first.TransactionID == "" ||
first, replay := purchaseStarsTwiceConcurrently(t, ctx, store, req)
if first.Duplicate || first.Balance.Balance != 2500 || first.TransactionID == "" ||
first.Send.SenderEvent.PtsCount != 1 || first.Send.RecipientEvent.PtsCount != 1 {
t.Fatalf("first purchase = %+v", first)
}
@ -66,10 +100,6 @@ func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T)
t.Fatalf("recipient gift action = %+v", action)
}
replay, err := store.PurchaseStarsGift(ctx, req)
if err != nil {
t.Fatalf("replay: %v", err)
}
if !replay.Duplicate || replay.TransactionID != first.TransactionID ||
replay.Send.SenderMessage.ID != first.Send.SenderMessage.ID || replay.Send.SenderEvent.Pts != first.Send.SenderEvent.Pts {
t.Fatalf("replay = %+v, first=%+v", replay, first)
@ -82,7 +112,7 @@ func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T)
if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_transactions WHERE user_id=$1 AND reason='gift'", recipient.ID).Scan(&txnCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_gift_purchase_commands WHERE buyer_user_id=$1", buyer.ID).Scan(&commandCount); err != nil {
if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_purchase_commands WHERE buyer_user_id=$1", buyer.ID).Scan(&commandCount); err != nil {
t.Fatal(err)
}
if balance != 2500 || txnCount != 1 || commandCount != 1 {
@ -103,11 +133,11 @@ func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T)
tampered := req
tampered.Amount++
if _, err := store.PurchaseStarsGift(ctx, tampered); !errors.Is(err, domain.ErrStarsGiftFormInvalid) {
if _, err := store.PurchaseStars(ctx, tampered); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) {
t.Fatalf("tampered replay err=%v", err)
}
expired, err := store.IssueStarsGiftPurchaseForm(ctx, domain.StarsGiftPurchaseForm{
BuyerUserID: buyer.ID, RecipientUserID: recipient.ID,
expired, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGift, BuyerUserID: buyer.ID, RecipientUserID: recipient.ID,
Stars: 1000, Currency: "USD", Amount: 99,
IssuedAt: 1_699_999_000, ExpiresAt: 1_699_999_600,
})
@ -116,10 +146,221 @@ func TestStarsFriendGiftPurchaseAtomicReplayAndValidationPostgres(t *testing.T)
}
expiredReq := req
expiredReq.FormID, expiredReq.Stars, expiredReq.Amount = expired.FormID, 1000, 99
if _, err := store.PurchaseStarsGift(ctx, expiredReq); !errors.Is(err, domain.ErrStarsGiftFormExpired) {
if _, err := store.PurchaseStars(ctx, expiredReq); !errors.Is(err, domain.ErrStarsPurchaseFormExpired) {
t.Fatalf("expired form err=%v", err)
}
if err := pool.QueryRow(ctx, "SELECT balance FROM stars_balances WHERE user_id=$1", recipient.ID).Scan(&balance); err != nil || balance != 2500 {
t.Fatalf("balance after failures=%d err=%v", balance, err)
}
}
func TestStarsTopupPurchaseAtomicReplayAndPurposeBindingPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
suffix := randomSuffix(t)
buyer, err := users.Create(ctx, domain.User{AccessHash: 94201, Phone: "+1665942" + suffix + "01", FirstName: "TopupBuyer"})
if err != nil {
t.Fatalf("create buyer: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_commands WHERE buyer_user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_forms WHERE buyer_user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_transactions WHERE user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_balances WHERE user_id=$1", buyer.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=$1", buyer.ID)
})
store := NewStarsPurchaseStore(pool, nil)
purposePeer := domain.Peer{Type: domain.PeerTypeUser, ID: buyer.ID + 100}
issued, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseTopup, BuyerUserID: buyer.ID, SpendPurposePeer: purposePeer,
Stars: 2500, Currency: "USD", Amount: 199,
IssuedAt: 1_700_000_000, ExpiresAt: 1_700_000_600,
})
if err != nil || issued.FormID == 0 {
t.Fatalf("issue form = %+v err=%v", issued, err)
}
req := domain.StarsPurchaseRequest{
StarsPurchaseForm: domain.StarsPurchaseForm{
FormID: issued.FormID, Kind: domain.StarsPurchaseTopup, BuyerUserID: buyer.ID,
SpendPurposePeer: purposePeer, Stars: 2500, Currency: "USD", Amount: 199,
},
Date: 1_700_000_100,
}
first, replay := purchaseStarsTwiceConcurrently(t, ctx, store, req)
if first.Duplicate || first.Balance.Balance != 2500 || first.TransactionID == "" {
t.Fatalf("first purchase = %+v", first)
}
if !replay.Duplicate || replay.Balance.Balance != 2500 || replay.TransactionID != first.TransactionID {
t.Fatalf("replay = %+v, first=%+v", replay, first)
}
var balance, txnCount, commandCount int64
if err := pool.QueryRow(ctx, "SELECT balance FROM stars_balances WHERE user_id=$1", buyer.ID).Scan(&balance); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_transactions WHERE user_id=$1 AND reason='topup'", buyer.ID).Scan(&txnCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "SELECT count(*) FROM stars_purchase_commands WHERE buyer_user_id=$1", buyer.ID).Scan(&commandCount); err != nil {
t.Fatal(err)
}
if balance != 2500 || txnCount != 1 || commandCount != 1 {
t.Fatalf("replay footprint balance=%d txns=%d commands=%d", balance, txnCount, commandCount)
}
tampered := req
tampered.SpendPurposePeer.ID++
if _, err := store.PurchaseStars(ctx, tampered); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) {
t.Fatalf("tampered purpose replay err=%v", err)
}
otherBuyer := req
otherBuyer.BuyerUserID++
if _, err := store.PurchaseStars(ctx, otherBuyer); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) {
t.Fatalf("cross-account form err=%v", err)
}
if err := pool.QueryRow(ctx, "SELECT balance FROM stars_balances WHERE user_id=$1", buyer.ID).Scan(&balance); err != nil || balance != 2500 {
t.Fatalf("balance after invalid submissions=%d err=%v", balance, err)
}
}
func TestStarsGiveawayPurchaseAtomicChannelPTSReplayAndInfoPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
suffix := randomSuffix(t)
owner, err := users.Create(ctx, domain.User{AccessHash: 94301, Phone: "+1665943" + suffix + "01", FirstName: "GiveawayOwner", CountryCode: "1"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := users.Create(ctx, domain.User{AccessHash: 94302, Phone: "+1665943" + suffix + "02", FirstName: "GiveawayMember", CountryCode: "1"})
if err != nil {
t.Fatalf("create member: %v", err)
}
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Stars Giveaway " + suffix, Megagroup: true,
MemberUserIDs: []int64{member.ID}, Date: 1_700_000_000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID := created.Channel.ID
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM stars_giveaways WHERE buyer_user_id=$1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_commands WHERE buyer_user_id=$1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM stars_purchase_forms WHERE buyer_user_id=$1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id=$1", channelID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{owner.ID, member.ID})
})
before, err := channels.GetChannelByID(ctx, channelID)
if err != nil {
t.Fatal(err)
}
purpose := &domain.StarsGiveawayPurchase{
BoostPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
CountriesISO2: []string{"US"}, RandomID: 9430001, UntilDate: 1_700_003_700,
Users: 2, PerUserStars: 500, YearlyBoosts: 4, WinnersAreVisible: true,
}
store := NewStarsPurchaseStore(pool, nil, channels)
issued, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGiveaway, BuyerUserID: owner.ID, Giveaway: purpose,
Stars: 1000, Currency: "USD", Amount: 99, IssuedAt: 1_700_000_100, ExpiresAt: 1_700_000_700,
})
if err != nil || issued.FormID == 0 {
t.Fatalf("issue giveaway form=%+v err=%v", issued, err)
}
req := domain.StarsPurchaseRequest{StarsPurchaseForm: domain.StarsPurchaseForm{
FormID: issued.FormID, Kind: domain.StarsPurchaseGiveaway, BuyerUserID: owner.ID, Giveaway: purpose,
Stars: 1000, Currency: "USD", Amount: 99,
}, Date: 1_700_000_200}
first, replay := purchaseStarsTwiceConcurrently(t, ctx, store, req)
if first.Duplicate || first.TransactionID == "" || first.ChannelSend.Event.PtsCount != 1 ||
first.ChannelSend.Event.Pts != before.Pts+1 || first.ChannelSend.Message.Media == nil ||
first.ChannelSend.Message.Media.Giveaway == nil {
t.Fatalf("first giveaway result=%+v before_pts=%d", first, before.Pts)
}
media := first.ChannelSend.Message.Media.Giveaway
if media.Stars != 1000 || media.Quantity != 2 || len(media.Channels) != 1 || media.Channels[0] != channelID ||
media.UntilDate != purpose.UntilDate || !media.WinnersAreVisible {
t.Fatalf("giveaway media=%+v", media)
}
difference, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: member.ID, ChannelID: channelID, Pts: before.Pts, Limit: 10,
})
if err != nil || difference.Pts != first.ChannelSend.Event.Pts || len(difference.Events) != 1 || len(difference.NewMessages) != 1 ||
difference.NewMessages[0].Media == nil || difference.NewMessages[0].Media.Giveaway == nil ||
difference.NewMessages[0].Media.Giveaway.Stars != 1000 {
t.Fatalf("giveaway channel difference=%+v err=%v", difference, err)
}
if !replay.Duplicate || replay.TransactionID != first.TransactionID ||
replay.ChannelSend.Message.ID != first.ChannelSend.Message.ID || replay.ChannelSend.Event.Pts != first.ChannelSend.Event.Pts {
t.Fatalf("giveaway replay=%+v first=%+v", replay, first)
}
lateReplayReq := req
lateReplayReq.Date = purpose.UntilDate
lateReplay, err := store.PurchaseStars(ctx, lateReplayReq)
if err != nil || !lateReplay.Duplicate || lateReplay.TransactionID != first.TransactionID ||
lateReplay.ChannelSend.Message.ID != first.ChannelSend.Message.ID || lateReplay.ChannelSend.Event.Pts != first.ChannelSend.Event.Pts {
t.Fatalf("giveaway replay after until_date=%+v err=%v first=%+v", lateReplay, err, first)
}
latePurpose := *purpose
latePurpose.RandomID++
lateForm, err := store.IssueStarsPurchaseForm(ctx, domain.StarsPurchaseForm{
Kind: domain.StarsPurchaseGiveaway, BuyerUserID: owner.ID, Giveaway: &latePurpose,
Stars: 1000, Currency: "USD", Amount: 99,
IssuedAt: purpose.UntilDate - 100, ExpiresAt: purpose.UntilDate + 500,
})
if err != nil {
t.Fatalf("issue giveaway form before until_date: %v", err)
}
lateFirstReq := req
lateFirstReq.FormID = lateForm.FormID
lateFirstReq.Giveaway = &latePurpose
lateFirstReq.Date = purpose.UntilDate
if _, err := store.PurchaseStars(ctx, lateFirstReq); !errors.Is(err, domain.ErrStarsPurchaseFormExpired) {
t.Fatalf("first giveaway settlement at until_date err=%v, want form expired", err)
}
var campaigns, commands, messages, events, balanceRows, txns int64
queries := []struct {
query string
args []any
target *int64
}{
{"SELECT count(*) FROM stars_giveaways WHERE buyer_user_id=$1", []any{owner.ID}, &campaigns},
{"SELECT count(*) FROM stars_purchase_commands WHERE buyer_user_id=$1", []any{owner.ID}, &commands},
{"SELECT count(*) FROM channel_messages WHERE channel_id=$1 AND id=$2", []any{channelID, first.ChannelSend.Message.ID}, &messages},
{"SELECT count(*) FROM channel_update_events WHERE channel_id=$1 AND pts=$2", []any{channelID, first.ChannelSend.Event.Pts}, &events},
{"SELECT count(*) FROM stars_balances WHERE user_id=$1", []any{owner.ID}, &balanceRows},
{"SELECT count(*) FROM stars_transactions WHERE user_id=$1", []any{owner.ID}, &txns},
}
for _, item := range queries {
if err := pool.QueryRow(ctx, item.query, item.args...).Scan(item.target); err != nil {
t.Fatalf("footprint query %q: %v", item.query, err)
}
}
if campaigns != 1 || commands != 1 || messages != 1 || events != 1 || balanceRows != 0 || txns != 0 {
t.Fatalf("footprint campaigns=%d commands=%d messages=%d events=%d balances=%d txns=%d", campaigns, commands, messages, events, balanceRows, txns)
}
ownerInfo, err := store.GetStarsGiveawayInfo(ctx, owner.ID, channelID, first.ChannelSend.Message.ID, 1_700_000_300)
if err != nil || ownerInfo.AdminDisallowedChatID != channelID || ownerInfo.Participating {
t.Fatalf("owner giveaway info=%+v err=%v", ownerInfo, err)
}
memberInfo, err := store.GetStarsGiveawayInfo(ctx, member.ID, channelID, first.ChannelSend.Message.ID, 1_700_000_300)
if err != nil || !memberInfo.Participating || memberInfo.StartDate != req.Date {
t.Fatalf("member giveaway info=%+v err=%v", memberInfo, err)
}
preparing, err := store.GetStarsGiveawayInfo(ctx, member.ID, channelID, first.ChannelSend.Message.ID, purpose.UntilDate)
if err != nil || !preparing.PreparingResults || preparing.Participating {
t.Fatalf("preparing giveaway info=%+v err=%v", preparing, err)
}
tampered := req
changed := *purpose
changed.Users, changed.PerUserStars = 1, 1000
tampered.Giveaway = &changed
if _, err := store.PurchaseStars(ctx, tampered); !errors.Is(err, domain.ErrStarsPurchaseFormInvalid) {
t.Fatalf("tampered giveaway replay err=%v", err)
}
}

View file

@ -23,10 +23,16 @@ type StarsStore interface {
ListTransactions(ctx context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error)
}
// StarsGiftPurchaseStore owns the fiat gift aggregate. A successful purchase
// must commit the recipient balance/ledger and both account message-box events
// in the same transaction; exact form retries return the original receipt.
type StarsGiftPurchaseStore interface {
IssueStarsGiftPurchaseForm(context.Context, domain.StarsGiftPurchaseForm) (domain.StarsGiftPurchaseForm, error)
PurchaseStarsGift(context.Context, domain.StarsGiftPurchaseRequest) (domain.StarsGiftPurchaseResult, error)
// StarsPurchaseStore owns fiat self-topup, friend-gift and giveaway-launch
// aggregates. Successful settlement commits the affected ledger/message box
// atomically; exact form retries return the original receipt.
type StarsPurchaseStore interface {
IssueStarsPurchaseForm(context.Context, domain.StarsPurchaseForm) (domain.StarsPurchaseForm, error)
PurchaseStars(context.Context, domain.StarsPurchaseRequest) (domain.StarsPurchaseResult, error)
}
// StarsGiveawayStore exposes the viewer-specific state of launch cards without
// forcing lightweight purchase-store fakes to implement the read model.
type StarsGiveawayStore interface {
GetStarsGiveawayInfo(context.Context, int64, int64, int, int) (domain.StarsGiveawayInfo, error)
}

View file

@ -173,6 +173,7 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", h.healthz)
mux.HandleFunc("GET /payments/dev-stars", h.devStarsCheckout)
mux.HandleFunc("GET /_public/avatar/{username}/{photoID}", h.publicAvatar)
mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers)
mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji)
@ -229,6 +230,43 @@ type moderationAppealPage struct {
CanSubmit bool
}
type devStarsCheckoutPage struct {
AppName string
FormID string
}
var devStarsCheckoutTemplate = template.Must(template.New("dev-stars-checkout").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer"><meta name="robots" content="noindex,nofollow">
<title>Dev Stars checkout · {{.AppName}}</title><style>
body{font:16px/1.5 system-ui,sans-serif;background:#f4f6f8;color:#17212b;margin:0;padding:24px}.card{max-width:520px;margin:9vh auto;background:#fff;border-radius:16px;padding:28px;box-shadow:0 8px 32px #0002}h1{margin-top:0}.note{color:#53606d}.status{min-height:24px;color:#b42318}button{width:100%;border:0;border-radius:10px;padding:13px 18px;background:#2481cc;color:#fff;font:inherit;font-weight:650;cursor:pointer}button:disabled{opacity:.55;cursor:default}
</style></head><body><main class="card"><h1>Complete dev purchase</h1>
<p>This is a local telesrv test checkout. No card, Google Play, App Store, or external payment provider will be charged.</p>
<p class="note">The package and fiat amount shown by the client are bound to form {{.FormID}}.</p>
<button id="complete" type="button">Complete test purchase</button><p id="status" class="status" role="status"></p>
</main><script>
(() => { const button=document.getElementById('complete'), status=document.getElementById('status');
button.addEventListener('click', () => { const proxy=window.TelegramWebviewProxy;
if(!proxy || typeof proxy.postEvent !== 'function'){status.textContent='Open this checkout inside Telegram.';return;}
button.disabled=true;status.textContent='Submitting';
proxy.postEvent('payment_form_submit', JSON.stringify({title:'telesrv dev payment',credentials:{type:'telesrv_dev',form_id:'{{.FormID}}'}}));
}); })();
</script></body></html>`))
func (h *handler) devStarsCheckout(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimSpace(r.URL.Query().Get("form_id"))
formID, err := strconv.ParseInt(raw, 10, 64)
if err != nil || formID == 0 || raw != strconv.FormatInt(formID, 10) {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := devStarsCheckoutTemplate.Execute(w, devStarsCheckoutPage{AppName: h.appName, FormID: raw}); err != nil {
h.logger.Warn("render dev Stars checkout failed", zap.Error(err))
}
}
var moderationAppealTemplate = template.Must(template.New("moderation-appeal").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer"><title>Moderation appeal · {{.AppName}}</title><style>

View file

@ -45,6 +45,32 @@ func newTestHandlerWithPublicPeers(
return h
}
func TestDevStarsCheckoutEmitsFormBoundTelegramCredentials(t *testing.T) {
h := newTestHandler(t, fakeResolver{}, "https://links.example.test")
for _, target := range []string{
"/payments/dev-stars",
"/payments/dev-stars?form_id=0",
"/payments/dev-stars?form_id=01",
"/payments/dev-stars?form_id=not-a-number",
} {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, target, nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("GET %s status = %d, want 404", target, rr.Code)
}
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/payments/dev-stars?form_id=-70001", nil))
body := rr.Body.String()
if rr.Code != http.StatusOK || rr.Header().Get("Cache-Control") != "no-store" ||
!strings.Contains(body, "payment_form_submit") ||
!strings.Contains(body, "type:'telesrv_dev',form_id:'-70001'") ||
!strings.Contains(body, "No card, Google Play, App Store, or external payment provider will be charged") {
t.Fatalf("dev checkout status=%d headers=%v body=%q", rr.Code, rr.Header(), body)
}
}
type fakeGiftWithdrawals struct {
value domain.StarGiftWithdrawal
found bool