diff --git a/cmd/herosms-catalog/main.go b/cmd/herosms-catalog/main.go new file mode 100644 index 0000000..8bb0360 --- /dev/null +++ b/cmd/herosms-catalog/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/byte-v-forge/sms/internal/catalog" + "github.com/byte-v-forge/sms/internal/providers/herosms" +) + +func main() { + apiKey := flag.String("api-key", os.Getenv("HERO_SMS_API_KEY"), "HeroSMS API key. Can also use HERO_SMS_API_KEY.") + endpoint := flag.String("endpoint", herosms.DefaultEndpoint, "HeroSMS handler API endpoint.") + countryFilter := flag.String("country", "", "Optional HeroSMS country id filter, for example 6.") + serviceFilter := flag.String("service", "", "Optional HeroSMS service code filter, for example tg.") + minCount := flag.Int("min-count", 1, "Minimum available count to include.") + limit := flag.Int("limit", 100, "Maximum rows to print. Use 0 for all rows.") + format := flag.String("format", "table", "Output format: table or json.") + flag.Parse() + + if strings.TrimSpace(*apiKey) == "" { + fmt.Fprintln(os.Stderr, "missing HeroSMS API key: pass -api-key or set HERO_SMS_API_KEY") + os.Exit(2) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + result, err := catalog.List(ctx, catalog.ProviderConfig{ + ProviderKey: herosms.ProviderKey, + APIKey: *apiKey, + Endpoint: *endpoint, + }, catalog.ListOptions{ + ServiceKey: *serviceFilter, + CountryID: *countryFilter, + MinCount: *minCount, + Limit: *limit, + }) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + if *format == "json" { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(result); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + + fmt.Printf("HeroSMS balance: %s\n", result.Balance.AmountDecimal) + fmt.Printf("Rows: %d", len(result.Options)) + if *countryFilter != "" || *serviceFilter != "" { + fmt.Printf(" (country=%q service=%q)", *countryFilter, *serviceFilter) + } + fmt.Println() + fmt.Println("country_id\tcountry\tservice_code\tservice\tcost\tcount\tphysical\troute") + for _, option := range result.Options { + fmt.Printf("%s\t%s\t%s\t%s\t%s\t%d\t%d\tprovider=%s application=%s upstream_service=%s provider_country=%s\n", + option.ProviderCountryID, + firstNonEmpty(option.CountryNameCN, option.CountryName), + option.UpstreamServiceKey, + option.ServiceName, + option.Price.AmountDecimal, + option.AvailableCount, + option.PhysicalCount, + option.ProviderKey, + option.ApplicationKey, + option.UpstreamServiceKey, + option.ProviderCountryID, + ) + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/cmd/provider-catalog/main.go b/cmd/provider-catalog/main.go new file mode 100644 index 0000000..a3662f4 --- /dev/null +++ b/cmd/provider-catalog/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/byte-v-forge/sms/internal/catalog" +) + +func main() { + provider := flag.String("provider", "", "Provider key: 5sim, herosms, or smsbower.") + apiKey := flag.String("api-key", "", "Provider API key/token. Can also use provider-specific env vars.") + endpoint := flag.String("endpoint", "", "Optional provider API endpoint override.") + currency := flag.String("currency", "", "Optional currency code, mainly for 5sim.") + country := flag.String("country", "", "Optional provider country id filter.") + service := flag.String("service", "", "Optional provider service/product code filter.") + operator := flag.String("operator", "", "Optional 5sim operator filter.") + minCount := flag.Int("min-count", 1, "Minimum available count to include.") + limit := flag.Int("limit", 50, "Maximum options to print. Use 0 for all options.") + format := flag.String("format", "table", "Output format: table or json.") + flag.Parse() + + key := firstNonEmpty(*apiKey, providerAPIKeyEnv(*provider)) + if strings.TrimSpace(*provider) == "" { + fmt.Fprintln(os.Stderr, "missing provider: pass -provider 5sim|herosms|smsbower") + os.Exit(2) + } + if key == "" { + fmt.Fprintln(os.Stderr, "missing provider API key: pass -api-key or set provider env var") + os.Exit(2) + } + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + result, err := catalog.List(ctx, catalog.ProviderConfig{ + ProviderKey: *provider, + APIKey: key, + Endpoint: *endpoint, + CurrencyCode: *currency, + }, catalog.ListOptions{ + ServiceKey: *service, + CountryID: *country, + Operator: *operator, + MinCount: *minCount, + Limit: *limit, + }) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if *format == "json" { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(result); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + + fmt.Printf("Provider: %s\n", result.ProviderKey) + fmt.Printf("Balance: %s %s\n", result.Balance.AmountDecimal, result.Balance.CurrencyCode) + fmt.Printf("Countries: %d Services: %d Options: %d\n", len(result.Countries), len(result.Services), len(result.Options)) + fmt.Println("country_id\tcountry\tservice_code\tservice\tprovider_or_operator\tcost\tcount\tphysical\troute") + for _, option := range result.Options { + upstream := firstNonEmpty(option.UpstreamProviderID, option.UpstreamOperator) + fmt.Printf("%s\t%s\t%s\t%s\t%s\t%s\t%d\t%d\tprovider=%s application=%s upstream_service=%s provider_country=%s\n", + option.ProviderCountryID, + firstNonEmpty(option.CountryNameCN, option.CountryName), + option.UpstreamServiceKey, + option.ServiceName, + upstream, + option.Price.AmountDecimal, + option.AvailableCount, + option.PhysicalCount, + option.ProviderKey, + option.ApplicationKey, + option.UpstreamServiceKey, + option.ProviderCountryID, + ) + } +} + +func providerAPIKeyEnv(provider string) string { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "5sim": + return os.Getenv("FIVESIM_TOKEN") + case "herosms": + return os.Getenv("HERO_SMS_API_KEY") + case "smsbower": + return os.Getenv("SMSBOWER_API_KEY") + default: + return "" + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go new file mode 100644 index 0000000..43bb381 --- /dev/null +++ b/internal/catalog/catalog.go @@ -0,0 +1,313 @@ +package catalog + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/byte-v-forge/sms/internal/core" + "github.com/byte-v-forge/sms/internal/providers/fivesim" + "github.com/byte-v-forge/sms/internal/providers/herosms" + "github.com/byte-v-forge/sms/internal/providers/smsbower" +) + +type ProviderConfig struct { + ProviderKey string + APIKey string + Endpoint string + CurrencyCode string +} + +type ListOptions struct { + ServiceKey string + CountryID string + Operator string + MinCount int + Limit int +} + +type Catalog struct { + ProviderKey string `json:"provider_key"` + Balance core.Money `json:"balance"` + Countries []Country `json:"countries"` + Services []Application `json:"services"` + Options []Option `json:"options"` +} + +type Country struct { + ProviderCountryID string `json:"provider_country_id"` + Name string `json:"name"` + NameCN string `json:"name_cn,omitempty"` + CountryISO2 string `json:"country_iso2,omitempty"` + CountryCallingCode string `json:"country_calling_code,omitempty"` + RetrySupported bool `json:"retry_supported,omitempty"` +} + +type Application struct { + ApplicationKey string `json:"application_key"` + UpstreamServiceKey string `json:"upstream_service_key"` + DisplayName string `json:"display_name"` +} + +type Option struct { + ProviderKey string `json:"provider_key"` + ApplicationKey string `json:"application_key"` + UpstreamServiceKey string `json:"upstream_service_key"` + ProviderCountryID string `json:"provider_country_id"` + CountryName string `json:"country_name,omitempty"` + CountryNameCN string `json:"country_name_cn,omitempty"` + CountryISO2 string `json:"country_iso2,omitempty"` + CountryCallingCode string `json:"country_calling_code,omitempty"` + ServiceName string `json:"service_name,omitempty"` + UpstreamProviderID string `json:"upstream_provider_id,omitempty"` + UpstreamOperator string `json:"upstream_operator,omitempty"` + Price core.Money `json:"price"` + AvailableCount int `json:"available_count"` + PhysicalCount int `json:"physical_count,omitempty"` + SuccessRate float64 `json:"success_rate,omitempty"` +} + +func List(ctx context.Context, config ProviderConfig, options ListOptions) (Catalog, error) { + switch strings.ToLower(strings.TrimSpace(config.ProviderKey)) { + case fivesim.ProviderKey: + return listFiveSim(ctx, config, options) + case herosms.ProviderKey: + return listHeroSMS(ctx, config, options) + case smsbower.ProviderKey: + return listSMSBower(ctx, config, options) + default: + return Catalog{}, core.NewError(core.CodeValidationFailed, "unsupported provider key", false) + } +} + +func listFiveSim(ctx context.Context, config ProviderConfig, options ListOptions) (Catalog, error) { + client, err := fivesim.New(fivesim.Config{ + Endpoint: config.Endpoint, + Token: config.APIKey, + CurrencyCode: firstNonEmpty(config.CurrencyCode, "RUB"), + DefaultOperator: firstNonEmpty(options.Operator, "any"), + }, nil) + if err != nil { + return Catalog{}, err + } + balance, err := client.GetBalance(ctx) + if err != nil { + return Catalog{}, err + } + countries, err := client.ListCountries(ctx) + if err != nil { + return Catalog{}, err + } + priceOffers, err := client.ListPriceOffers(ctx, options.ServiceKey, options.CountryID) + if err != nil { + return Catalog{}, err + } + + catalog := Catalog{ProviderKey: fivesim.ProviderKey, Balance: balance} + countryByID := make(map[string]Country, len(countries)) + for _, country := range countries { + item := Country{ + ProviderCountryID: country.CountryID, + Name: firstNonEmpty(country.Name, country.CountryID), + CountryISO2: strings.ToUpper(country.CountryISO2), + CountryCallingCode: country.CountryCallingCode, + } + catalog.Countries = append(catalog.Countries, item) + countryByID[country.CountryID] = item + } + + serviceSeen := map[string]bool{} + for _, offer := range priceOffers { + if offer.AvailableCount < options.MinCount { + continue + } + country := countryByID[offer.CountryID] + serviceSeen[offer.UpstreamServiceKey] = true + catalog.Options = append(catalog.Options, Option{ + ProviderKey: fivesim.ProviderKey, + ApplicationKey: offer.UpstreamServiceKey, + UpstreamServiceKey: offer.UpstreamServiceKey, + ProviderCountryID: offer.CountryID, + CountryName: country.Name, + CountryISO2: country.CountryISO2, + CountryCallingCode: country.CountryCallingCode, + ServiceName: offer.UpstreamServiceKey, + UpstreamProviderID: offer.Operator, + UpstreamOperator: offer.Operator, + Price: offer.Price, + AvailableCount: offer.AvailableCount, + SuccessRate: offer.SuccessRate, + }) + } + for service := range serviceSeen { + catalog.Services = append(catalog.Services, Application{ApplicationKey: service, UpstreamServiceKey: service, DisplayName: service}) + } + return finalize(catalog, options), nil +} + +func listHeroSMS(ctx context.Context, config ProviderConfig, options ListOptions) (Catalog, error) { + client, err := herosms.New(herosms.Config{Endpoint: config.Endpoint, APIKey: config.APIKey}, nil) + if err != nil { + return Catalog{}, err + } + balance, err := client.GetBalance(ctx) + if err != nil { + return Catalog{}, err + } + countries, err := client.ListCountries(ctx) + if err != nil { + return Catalog{}, err + } + services, err := client.ListApplications(ctx) + if err != nil { + return Catalog{}, err + } + priceOffers, err := client.ListPriceOffers(ctx, options.ServiceKey, options.CountryID) + if err != nil { + return Catalog{}, err + } + + catalog := Catalog{ProviderKey: herosms.ProviderKey, Balance: balance} + countryByID := make(map[string]Country, len(countries)) + for _, country := range countries { + item := Country{ + ProviderCountryID: country.CountryID, + Name: firstNonEmpty(country.Name, country.CountryID), + NameCN: country.NameCN, + RetrySupported: country.Retry, + } + catalog.Countries = append(catalog.Countries, item) + countryByID[country.CountryID] = item + } + serviceByKey := make(map[string]Application, len(services)) + for _, service := range services { + item := Application{ + ApplicationKey: service.ApplicationKey, + UpstreamServiceKey: service.UpstreamServiceKey, + DisplayName: service.DisplayName, + } + catalog.Services = append(catalog.Services, item) + serviceByKey[service.UpstreamServiceKey] = item + } + for _, offer := range priceOffers { + if offer.AvailableCount < options.MinCount { + continue + } + country := countryByID[offer.CountryID] + service := serviceByKey[offer.UpstreamServiceKey] + catalog.Options = append(catalog.Options, Option{ + ProviderKey: herosms.ProviderKey, + ApplicationKey: firstNonEmpty(service.ApplicationKey, offer.UpstreamServiceKey), + UpstreamServiceKey: offer.UpstreamServiceKey, + ProviderCountryID: offer.CountryID, + CountryName: country.Name, + CountryNameCN: country.NameCN, + ServiceName: firstNonEmpty(service.DisplayName, offer.UpstreamServiceKey), + Price: offer.Price, + AvailableCount: offer.AvailableCount, + PhysicalCount: offer.PhysicalCount, + }) + } + return finalize(catalog, options), nil +} + +func listSMSBower(ctx context.Context, config ProviderConfig, options ListOptions) (Catalog, error) { + client, err := smsbower.New(smsbower.Config{Endpoint: config.Endpoint, APIKey: config.APIKey}, nil) + if err != nil { + return Catalog{}, err + } + balance, err := client.GetBalance(ctx) + if err != nil { + return Catalog{}, err + } + countries, err := client.ListCountries(ctx) + if err != nil { + return Catalog{}, err + } + services, err := client.ListApplications(ctx) + if err != nil { + return Catalog{}, err + } + priceOffers, err := client.ListPriceOffers(ctx, options.ServiceKey, options.CountryID) + if err != nil { + return Catalog{}, err + } + + catalog := Catalog{ProviderKey: smsbower.ProviderKey, Balance: balance} + countryByID := make(map[string]Country, len(countries)) + for _, country := range countries { + item := Country{ProviderCountryID: country.CountryID, Name: firstNonEmpty(country.Name, country.CountryID)} + catalog.Countries = append(catalog.Countries, item) + countryByID[country.CountryID] = item + } + serviceByKey := make(map[string]Application, len(services)) + for _, service := range services { + item := Application{ + ApplicationKey: service.ApplicationKey, + UpstreamServiceKey: service.UpstreamServiceKey, + DisplayName: service.DisplayName, + } + catalog.Services = append(catalog.Services, item) + serviceByKey[service.UpstreamServiceKey] = item + } + for _, offer := range priceOffers { + if offer.AvailableCount < options.MinCount { + continue + } + country := countryByID[offer.CountryID] + service := serviceByKey[offer.UpstreamServiceKey] + catalog.Options = append(catalog.Options, Option{ + ProviderKey: smsbower.ProviderKey, + ApplicationKey: firstNonEmpty(service.ApplicationKey, offer.UpstreamServiceKey), + UpstreamServiceKey: offer.UpstreamServiceKey, + ProviderCountryID: offer.CountryID, + CountryName: country.Name, + ServiceName: firstNonEmpty(service.DisplayName, offer.UpstreamServiceKey), + UpstreamProviderID: offer.ProviderID, + Price: offer.Price, + AvailableCount: offer.AvailableCount, + }) + } + return finalize(catalog, options), nil +} + +func finalize(catalog Catalog, options ListOptions) Catalog { + sort.Slice(catalog.Countries, func(i, j int) bool { + return catalog.Countries[i].ProviderCountryID < catalog.Countries[j].ProviderCountryID + }) + sort.Slice(catalog.Services, func(i, j int) bool { + return catalog.Services[i].DisplayName < catalog.Services[j].DisplayName + }) + sort.Slice(catalog.Options, func(i, j int) bool { + leftScore := catalog.Options[i].PhysicalCount + rightScore := catalog.Options[j].PhysicalCount + if leftScore == 0 { + leftScore = catalog.Options[i].AvailableCount + } + if rightScore == 0 { + rightScore = catalog.Options[j].AvailableCount + } + if leftScore != rightScore { + return leftScore > rightScore + } + if catalog.Options[i].ProviderCountryID != catalog.Options[j].ProviderCountryID { + return catalog.Options[i].ProviderCountryID < catalog.Options[j].ProviderCountryID + } + return fmt.Sprintf("%s:%s", catalog.Options[i].UpstreamServiceKey, catalog.Options[i].UpstreamProviderID) < + fmt.Sprintf("%s:%s", catalog.Options[j].UpstreamServiceKey, catalog.Options[j].UpstreamProviderID) + }) + if options.Limit > 0 && len(catalog.Options) > options.Limit { + catalog.Options = catalog.Options[:options.Limit] + } + return catalog +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go new file mode 100644 index 0000000..6d5d74b --- /dev/null +++ b/internal/catalog/catalog_test.go @@ -0,0 +1,62 @@ +package catalog + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/byte-v-forge/sms/internal/providers/herosms" +) + +func TestListHeroSMSCatalogBuildsSelectableOptions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("action") { + case "getBalance": + _, _ = w.Write([]byte("ACCESS_BALANCE:1.363")) + case "getCountries": + _, _ = w.Write([]byte(`[{"id":6,"rus":"Индонезия","eng":"Indonesia","chn":"印度尼西亚","visible":1,"retry":1}]`)) + case "getServicesList": + _, _ = w.Write([]byte(`{"status":"success","services":[{"code":"tg","name":"Telegram"}]}`)) + case "getPrices": + if got := r.URL.Query().Get("country"); got != "6" { + t.Fatalf("country = %q, want 6", got) + } + if got := r.URL.Query().Get("service"); got != "tg" { + t.Fatalf("service = %q, want tg", got) + } + _, _ = w.Write([]byte(`{"6":{"tg":{"cost":0.15,"count":24794,"physicalCount":22846}}}`)) + default: + t.Fatalf("unexpected action %q", r.URL.Query().Get("action")) + } + })) + defer server.Close() + + result, err := List(context.Background(), ProviderConfig{ + ProviderKey: herosms.ProviderKey, + APIKey: "test-token", + Endpoint: server.URL, + }, ListOptions{CountryID: "6", ServiceKey: "tg", Limit: 10}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if result.ProviderKey != herosms.ProviderKey || result.Balance.AmountDecimal != "1.363" { + t.Fatalf("catalog header = %#v", result) + } + if len(result.Countries) != 1 || result.Countries[0].ProviderCountryID != "6" { + t.Fatalf("countries = %#v", result.Countries) + } + if len(result.Services) != 1 || result.Services[0].DisplayName != "Telegram" { + t.Fatalf("services = %#v", result.Services) + } + if len(result.Options) != 1 { + t.Fatalf("options = %#v", result.Options) + } + option := result.Options[0] + if option.ProviderKey != "herosms" || option.ApplicationKey != "tg" || option.UpstreamServiceKey != "tg" || option.ProviderCountryID != "6" { + t.Fatalf("route option = %#v", option) + } + if option.CountryNameCN != "印度尼西亚" || option.ServiceName != "Telegram" || option.Price.AmountDecimal != "0.15" || option.AvailableCount != 24794 || option.PhysicalCount != 22846 { + t.Fatalf("display option = %#v", option) + } +} diff --git a/internal/providers/fivesim/client.go b/internal/providers/fivesim/client.go index d6191d7..474e91e 100644 --- a/internal/providers/fivesim/client.go +++ b/internal/providers/fivesim/client.go @@ -17,8 +17,9 @@ import ( ) const ( - DefaultEndpoint = "https://5sim.net" - ProviderKey = "5sim" + DefaultEndpoint = "https://5sim.net" + ProviderKey = "5sim" + maxResponseBytes = 16 << 20 ) type Config struct { @@ -237,7 +238,7 @@ func (c *Client) getJSON(ctx context.Context, path string, params url.Values, au return core.NewError(core.CodeSupplyUnavailable, err.Error(), true) } defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { return core.NewError(core.CodeSupplyUnavailable, err.Error(), true) } diff --git a/internal/providers/handlerapi/client.go b/internal/providers/handlerapi/client.go index bdbcf0f..5c43872 100644 --- a/internal/providers/handlerapi/client.go +++ b/internal/providers/handlerapi/client.go @@ -23,6 +23,8 @@ type Client struct { userAgent string } +const maxResponseBytes = 16 << 20 + func New(endpoint, apiKey string, httpClient HTTPDoer) (*Client, error) { if strings.TrimSpace(endpoint) == "" { return nil, core.NewError(core.CodeValidationFailed, "handler api endpoint is required", false) @@ -68,7 +70,7 @@ func (c *Client) Do(ctx context.Context, action string, params url.Values) (stri return "", core.NewError(core.CodeSupplyUnavailable, err.Error(), true) } defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { return "", core.NewError(core.CodeSupplyUnavailable, err.Error(), true) } diff --git a/internal/providers/herosms/catalog.go b/internal/providers/herosms/catalog.go new file mode 100644 index 0000000..aa8ff18 --- /dev/null +++ b/internal/providers/herosms/catalog.go @@ -0,0 +1,161 @@ +package herosms + +import ( + "context" + "encoding/json" + "net/url" + "strconv" + + "github.com/byte-v-forge/sms/internal/core" + "github.com/byte-v-forge/sms/internal/providers/handlerapi" +) + +type ApplicationOffer struct { + ApplicationKey string + UpstreamServiceKey string + DisplayName string +} + +type Country struct { + CountryID string + Name string + NameCN string + Visible bool + Retry bool +} + +type PriceOffer struct { + CountryID string + UpstreamServiceKey string + Price core.Money + AvailableCount int + PhysicalCount int +} + +func (c *Client) ListApplications(ctx context.Context) ([]ApplicationOffer, error) { + result, err := c.api.Do(ctx, "getServicesList", nil) + if err != nil { + return nil, err + } + var payload struct { + Status string `json:"status"` + Services []struct { + Code string `json:"code"` + Name string `json:"name"` + } `json:"services"` + } + if err := decodeJSONObject(result, &payload); err != nil { + return nil, err + } + if payload.Status != "" && payload.Status != "success" { + return nil, handlerapi.MapTextError(payload.Status) + } + offers := make([]ApplicationOffer, 0, len(payload.Services)) + for _, service := range payload.Services { + offers = append(offers, ApplicationOffer{ + ApplicationKey: service.Code, + UpstreamServiceKey: service.Code, + DisplayName: firstNonEmpty(service.Name, service.Code), + }) + } + return offers, nil +} + +func (c *Client) ListCountries(ctx context.Context) ([]Country, error) { + result, err := c.api.Do(ctx, "getCountries", nil) + if err != nil { + return nil, err + } + var raw []struct { + ID int `json:"id"` + Rus string `json:"rus"` + Eng string `json:"eng"` + Chn string `json:"chn"` + Visible int `json:"visible"` + Retry int `json:"retry"` + } + if err := decodeJSONObject(result, &raw); err != nil { + return nil, err + } + countries := make([]Country, 0, len(raw)) + for _, item := range raw { + countries = append(countries, Country{ + CountryID: strconv.Itoa(item.ID), + Name: firstNonEmpty(item.Eng, item.Chn, item.Rus, strconv.Itoa(item.ID)), + NameCN: firstNonEmpty(item.Chn, item.Eng, item.Rus, strconv.Itoa(item.ID)), + Visible: item.Visible == 1, + Retry: item.Retry == 1, + }) + } + return countries, nil +} + +func (c *Client) ListPriceOffers(ctx context.Context, serviceKey, countryID string) ([]PriceOffer, error) { + params := url.Values{} + if serviceKey != "" { + params.Set("service", serviceKey) + } + if countryID != "" { + params.Set("country", countryID) + } + result, err := c.api.Do(ctx, "getPrices", params) + if err != nil { + return nil, err + } + var raw map[string]map[string]struct { + Cost json.RawMessage `json:"cost"` + Count int `json:"count"` + PhysicalCount int `json:"physicalCount"` + } + if err := decodeJSONObject(result, &raw); err != nil { + return nil, err + } + var offers []PriceOffer + for cID, byService := range raw { + for service, offer := range byService { + offers = append(offers, PriceOffer{ + CountryID: cID, + UpstreamServiceKey: service, + Price: core.Money{AmountDecimal: rawJSONScalar(offer.Cost)}, + AvailableCount: offer.Count, + PhysicalCount: offer.PhysicalCount, + }) + } + } + return offers, nil +} + +func decodeJSONObject(result string, out any) error { + if err := json.Unmarshal([]byte(result), out); err != nil { + return core.NewError(core.CodeUpstreamRejected, "bad json response: "+err.Error(), false) + } + return nil +} + +func rawJSONScalar(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + return text + } + var number json.Number + if err := json.Unmarshal(raw, &number); err == nil { + return number.String() + } + var floatValue float64 + if err := json.Unmarshal(raw, &floatValue); err == nil { + return strconv.FormatFloat(floatValue, 'f', -1, 64) + } + return string(raw) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/providers/herosms/client_test.go b/internal/providers/herosms/client_test.go index 6449b52..4299e76 100644 --- a/internal/providers/herosms/client_test.go +++ b/internal/providers/herosms/client_test.go @@ -68,3 +68,51 @@ func TestPolicyRequiresTwoMinutesBeforeCancel(t *testing.T) { t.Fatalf("CancelAllowedAfter = %s, want 2m", got) } } + +func TestListCatalogData(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("action") { + case "getCountries": + _, _ = w.Write([]byte(`[{"id":6,"rus":"Индонезия","eng":"Indonesia","chn":"印度尼西亚","visible":1,"retry":1}]`)) + case "getServicesList": + _, _ = w.Write([]byte(`{"status":"success","services":[{"code":"tg","name":"Telegram"}]}`)) + case "getPrices": + if got := r.URL.Query().Get("country"); got != "6" { + t.Fatalf("country = %q, want 6", got) + } + if got := r.URL.Query().Get("service"); got != "tg" { + t.Fatalf("service = %q, want tg", got) + } + _, _ = w.Write([]byte(`{"6":{"tg":{"cost":0.15,"count":24794,"physicalCount":22846}}}`)) + default: + t.Fatalf("unexpected action %q", r.URL.Query().Get("action")) + } + })) + defer server.Close() + + client, err := New(Config{Endpoint: server.URL, APIKey: "test-token"}, server.Client()) + if err != nil { + t.Fatalf("New() error = %v", err) + } + countries, err := client.ListCountries(context.Background()) + if err != nil { + t.Fatalf("ListCountries() error = %v", err) + } + if len(countries) != 1 || countries[0].CountryID != "6" || countries[0].NameCN != "印度尼西亚" || !countries[0].Retry { + t.Fatalf("countries = %#v", countries) + } + services, err := client.ListApplications(context.Background()) + if err != nil { + t.Fatalf("ListApplications() error = %v", err) + } + if len(services) != 1 || services[0].UpstreamServiceKey != "tg" || services[0].DisplayName != "Telegram" { + t.Fatalf("services = %#v", services) + } + offers, err := client.ListPriceOffers(context.Background(), "tg", "6") + if err != nil { + t.Fatalf("ListPriceOffers() error = %v", err) + } + if len(offers) != 1 || offers[0].CountryID != "6" || offers[0].UpstreamServiceKey != "tg" || offers[0].Price.AmountDecimal != "0.15" || offers[0].AvailableCount != 24794 || offers[0].PhysicalCount != 22846 { + t.Fatalf("offers = %#v", offers) + } +}