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

@ -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 {