mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-07-25 10:22:21 +00:00
feat(coin): allow upstream-supported tickers
This commit is contained in:
@@ -61,9 +61,45 @@ func TestResolveCoinSymbol(t *testing.T) {
|
||||
if err != nil || coin.Symbol != "BTC" || coin.CoinGeckoID != "bitcoin" {
|
||||
t.Fatalf("ResolveCoinSymbol btc = %+v, %v", coin, err)
|
||||
}
|
||||
if _, err := ResolveCoinSymbol("NOPE"); !errors.Is(err, ErrUnsupportedCoin) {
|
||||
coin, err = ResolveCoinSymbol("ena")
|
||||
if err != nil || coin.Symbol != "ENA" || coin.CoinGeckoID != "" {
|
||||
t.Fatalf("ResolveCoinSymbol ena = %+v, %v", coin, err)
|
||||
}
|
||||
for _, input := range []string{"", "123", "BAD-COIN", strings.Repeat("A", maxCoinSymbolLength+1)} {
|
||||
if _, err := ResolveCoinSymbol(input); !errors.Is(err, ErrUnsupportedCoin) {
|
||||
t.Fatalf("ResolveCoinSymbol(%q) got %v, want ErrUnsupportedCoin", input, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePriceAllowsUnlistedTicker(t *testing.T) {
|
||||
s := newTestState(map[string]CoinPrice{"ENA": {USD: 0.08, Source: "Binance"}}, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/coin_price ena")); err != nil {
|
||||
t.Fatalf("handlePrice: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "ENA price: $0.08 (Binance)")
|
||||
}
|
||||
|
||||
func TestHandlePriceReturnsNoPriceForUnlistedTickerWhenProvidersFail(t *testing.T) {
|
||||
s := newTestState(nil, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/coin_price nope")); err != nil {
|
||||
t.Fatalf("handlePrice: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "No coin price available")
|
||||
}
|
||||
|
||||
func TestHandlePriceRejectsMalformedCoinTicker(t *testing.T) {
|
||||
s := newTestState(nil, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/coin_price bad-coin")); err != nil {
|
||||
t.Fatalf("handlePrice: %v", err)
|
||||
}
|
||||
if _, err := ResolveCoinSymbol("bad-coin"); !errors.Is(err, ErrUnsupportedCoin) {
|
||||
t.Fatalf("got %v, want ErrUnsupportedCoin", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Invalid coin ticker")
|
||||
}
|
||||
|
||||
func TestModuleRegistersExpectedCommands(t *testing.T) {
|
||||
@@ -88,15 +124,6 @@ func TestHandlePrice(t *testing.T) {
|
||||
rb.AssertSentText(t, "BTC price: $67,000.00 (Binance)")
|
||||
}
|
||||
|
||||
func TestHandlePriceRejectsUnsupportedCoin(t *testing.T) {
|
||||
s := newTestState(nil, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/coin_price nope")); err != nil {
|
||||
t.Fatalf("handlePrice: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Unsupported coin")
|
||||
}
|
||||
|
||||
func TestHandleTopup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(nil, nil)
|
||||
|
||||
@@ -72,7 +72,7 @@ func isSafeUSD(n float64) bool {
|
||||
|
||||
func (s *state) replyPriceError(ctx context.Context, b *bot.Bot, update *models.Update, err error) error {
|
||||
if errors.Is(err, ErrUnsupportedCoin) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Unsupported coin. Supported: BTC, ETH, SOL, BNB, XRP, ADA, DOGE, TON.")
|
||||
return chathelper.Reply(ctx, b, update.Message, "Invalid coin ticker. Use 1-20 letters/numbers, with at least one letter.")
|
||||
}
|
||||
if errors.Is(err, ErrNoCoinPrice) {
|
||||
return chathelper.Reply(ctx, b, update.Message, "No coin price available.")
|
||||
|
||||
@@ -24,8 +24,9 @@ type CoinbaseProvider struct {
|
||||
}
|
||||
|
||||
type CoinGeckoProvider struct {
|
||||
HTTP *http.Client
|
||||
URL string
|
||||
HTTP *http.Client
|
||||
URL string
|
||||
SearchURL string
|
||||
}
|
||||
|
||||
type binanceResponse struct {
|
||||
@@ -44,6 +45,16 @@ type coinGeckoQuote struct {
|
||||
USD float64 `json:"usd"`
|
||||
}
|
||||
|
||||
type coinGeckoSearchResponse struct {
|
||||
Coins []coinGeckoSearchCoin `json:"coins"`
|
||||
}
|
||||
|
||||
type coinGeckoSearchCoin struct {
|
||||
ID string `json:"id"`
|
||||
Symbol string `json:"symbol"`
|
||||
MarketCapRank *int `json:"market_cap_rank"`
|
||||
}
|
||||
|
||||
func (p *BinanceProvider) FetchUSD(ctx context.Context, coin CoinSymbol) (CoinPrice, error) {
|
||||
for _, quote := range []string{"USDT", "USD"} {
|
||||
price, err := p.fetchPair(ctx, coin.Symbol, quote)
|
||||
@@ -127,12 +138,34 @@ func (p *CoinbaseProvider) baseURL() string {
|
||||
}
|
||||
|
||||
func (p *CoinGeckoProvider) FetchUSD(ctx context.Context, coin CoinSymbol) (CoinPrice, error) {
|
||||
if coin.CoinGeckoID != "" {
|
||||
return p.fetchID(ctx, coin, coin.CoinGeckoID)
|
||||
}
|
||||
price, err := p.fetchID(ctx, coin, strings.ToLower(coin.Symbol))
|
||||
if err == nil {
|
||||
return price, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNoCoinPrice) {
|
||||
return CoinPrice{}, err
|
||||
}
|
||||
id, err := p.searchID(ctx, coin.Symbol)
|
||||
if err != nil {
|
||||
return CoinPrice{}, err
|
||||
}
|
||||
return p.fetchID(ctx, coin, id)
|
||||
}
|
||||
|
||||
func (p *CoinGeckoProvider) fetchID(ctx context.Context, coin CoinSymbol, id string) (CoinPrice, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return CoinPrice{}, ErrNoCoinPrice
|
||||
}
|
||||
endpoint := p.baseURL()
|
||||
if err := validateEndpoint(endpoint); err != nil {
|
||||
return CoinPrice{}, err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("ids", coin.CoinGeckoID)
|
||||
q.Set("ids", id)
|
||||
q.Set("vs_currencies", "usd")
|
||||
q.Set("include_last_updated_at", "true")
|
||||
resp, err := getJSON(ctx, p.HTTP, endpoint+"?"+q.Encode())
|
||||
@@ -147,13 +180,57 @@ func (p *CoinGeckoProvider) FetchUSD(ctx context.Context, coin CoinSymbol) (Coin
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return CoinPrice{}, fmt.Errorf("coin: CoinGecko decode: %w", err)
|
||||
}
|
||||
quote := body[coin.CoinGeckoID]
|
||||
quote := body[id]
|
||||
if quote.USD <= 0 {
|
||||
return CoinPrice{}, ErrNoCoinPrice
|
||||
}
|
||||
return CoinPrice{Symbol: coin.Symbol, USD: quote.USD, Source: "CoinGecko"}, nil
|
||||
}
|
||||
|
||||
func (p *CoinGeckoProvider) searchID(ctx context.Context, symbol string) (string, error) {
|
||||
endpoint := p.searchURL()
|
||||
if err := validateEndpoint(endpoint); err != nil {
|
||||
return "", err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("query", symbol)
|
||||
resp, err := getJSON(ctx, p.HTTP, endpoint+"?"+q.Encode())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("coin: CoinGecko search request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", ErrNoCoinPrice
|
||||
}
|
||||
var body coinGeckoSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return "", fmt.Errorf("coin: CoinGecko search decode: %w", err)
|
||||
}
|
||||
best := coinGeckoSearchCoin{}
|
||||
for _, candidate := range body.Coins {
|
||||
if candidate.ID == "" || !strings.EqualFold(candidate.Symbol, symbol) {
|
||||
continue
|
||||
}
|
||||
if best.ID == "" || betterCoinGeckoSearchMatch(candidate, best) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
if best.ID == "" {
|
||||
return "", ErrNoCoinPrice
|
||||
}
|
||||
return best.ID, nil
|
||||
}
|
||||
|
||||
func betterCoinGeckoSearchMatch(candidate, current coinGeckoSearchCoin) bool {
|
||||
if candidate.MarketCapRank == nil {
|
||||
return false
|
||||
}
|
||||
if current.MarketCapRank == nil {
|
||||
return true
|
||||
}
|
||||
return *candidate.MarketCapRank < *current.MarketCapRank
|
||||
}
|
||||
|
||||
func (p *CoinGeckoProvider) baseURL() string {
|
||||
if strings.TrimSpace(p.URL) != "" {
|
||||
return strings.TrimSpace(p.URL)
|
||||
@@ -161,6 +238,13 @@ func (p *CoinGeckoProvider) baseURL() string {
|
||||
return coinGeckoDefaultURL
|
||||
}
|
||||
|
||||
func (p *CoinGeckoProvider) searchURL() string {
|
||||
if strings.TrimSpace(p.SearchURL) != "" {
|
||||
return strings.TrimSpace(p.SearchURL)
|
||||
}
|
||||
return coinGeckoSearchURL
|
||||
}
|
||||
|
||||
func getJSON(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
binanceDefaultURL = "https://data-api.binance.vision/api/v3/ticker/price"
|
||||
coinbaseDefaultURL = "https://api.coinbase.com/v2/exchange-rates"
|
||||
coinGeckoDefaultURL = "https://api.coingecko.com/api/v3/simple/price"
|
||||
coinGeckoSearchURL = "https://api.coingecko.com/api/v3/search"
|
||||
// coinHTTPTimeout caps a single provider call, kept under the handler
|
||||
// deadline so one slow provider cannot starve the Telegram reply budget
|
||||
// (see chathelper.FetchContext).
|
||||
|
||||
@@ -28,6 +28,24 @@ func TestBinanceProviderFetchUSD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinanceProviderFetchesUnlistedTicker(t *testing.T) {
|
||||
coin := CoinSymbol{Symbol: "ENA"}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("symbol"); got != "ENAUSDT" {
|
||||
t.Fatalf("symbol = %q, want ENAUSDT", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"symbol":"ENAUSDT","price":"0.077"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
price, err := (&BinanceProvider{URL: srv.URL}).FetchUSD(context.Background(), coin)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchUSD: %v", err)
|
||||
}
|
||||
if price.USD != 0.077 || price.Source != "Binance" || price.Symbol != "ENA" {
|
||||
t.Fatalf("price = %+v", price)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinanceRateLimitDoesNotTrySecondPair(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -78,6 +96,61 @@ func TestCoinGeckoProviderFetchUSD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoinGeckoProviderFetchesUnmappedTickerByLowercaseID(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("ids"); got != "re" {
|
||||
t.Fatalf("ids = %q, want re", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"re":{"usd":0.64}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
price, err := (&CoinGeckoProvider{URL: srv.URL}).FetchUSD(context.Background(), CoinSymbol{Symbol: "RE"})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchUSD: %v", err)
|
||||
}
|
||||
if price.USD != 0.64 || price.Source != "CoinGecko" {
|
||||
t.Fatalf("price = %+v", price)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoinGeckoProviderSearchesUnmappedTickerWhenIDDiffers(t *testing.T) {
|
||||
var simpleIDs []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/simple":
|
||||
id := r.URL.Query().Get("ids")
|
||||
simpleIDs = append(simpleIDs, id)
|
||||
if id == "ethena" {
|
||||
_, _ = w.Write([]byte(`{"ethena":{"usd":0.077}}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
case "/search":
|
||||
if got := r.URL.Query().Get("query"); got != "ENA" {
|
||||
t.Fatalf("query = %q, want ENA", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"coins":[{"id":"ethena-usde","symbol":"USDE","market_cap_rank":27},{"id":"ethena","symbol":"ENA","market_cap_rank":85}]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
price, err := (&CoinGeckoProvider{
|
||||
URL: srv.URL + "/simple",
|
||||
SearchURL: srv.URL + "/search",
|
||||
}).FetchUSD(context.Background(), CoinSymbol{Symbol: "ENA"})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchUSD: %v", err)
|
||||
}
|
||||
if price.USD != 0.077 || price.Source != "CoinGecko" {
|
||||
t.Fatalf("price = %+v", price)
|
||||
}
|
||||
if len(simpleIDs) != 2 || simpleIDs[0] != "ena" || simpleIDs[1] != "ethena" {
|
||||
t.Fatalf("simple ids = %v, want [ena ethena]", simpleIDs)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeProvider struct {
|
||||
price CoinPrice
|
||||
err error
|
||||
|
||||
@@ -3,6 +3,7 @@ package coin
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var ErrUnsupportedCoin = errors.New("coin: unsupported coin")
|
||||
@@ -12,25 +13,37 @@ type CoinSymbol struct {
|
||||
CoinGeckoID string
|
||||
}
|
||||
|
||||
var supportedCoins = map[string]CoinSymbol{
|
||||
"BTC": {Symbol: "BTC", CoinGeckoID: "bitcoin"},
|
||||
"ETH": {Symbol: "ETH", CoinGeckoID: "ethereum"},
|
||||
"SOL": {Symbol: "SOL", CoinGeckoID: "solana"},
|
||||
"BNB": {Symbol: "BNB", CoinGeckoID: "binancecoin"},
|
||||
"XRP": {Symbol: "XRP", CoinGeckoID: "ripple"},
|
||||
"ADA": {Symbol: "ADA", CoinGeckoID: "cardano"},
|
||||
"DOGE": {Symbol: "DOGE", CoinGeckoID: "dogecoin"},
|
||||
"TON": {Symbol: "TON", CoinGeckoID: "the-open-network"},
|
||||
const maxCoinSymbolLength = 20
|
||||
|
||||
var knownCoinGeckoIDs = map[string]string{
|
||||
"BTC": "bitcoin",
|
||||
"ETH": "ethereum",
|
||||
"SOL": "solana",
|
||||
"BNB": "binancecoin",
|
||||
"XRP": "ripple",
|
||||
"ADA": "cardano",
|
||||
"DOGE": "dogecoin",
|
||||
"TON": "the-open-network",
|
||||
}
|
||||
|
||||
func ResolveCoinSymbol(input string) (CoinSymbol, error) {
|
||||
symbol := strings.ToUpper(strings.TrimSpace(input))
|
||||
if symbol == "" {
|
||||
if !validCoinSymbol(symbol) {
|
||||
return CoinSymbol{}, ErrUnsupportedCoin
|
||||
}
|
||||
coin, ok := supportedCoins[symbol]
|
||||
if !ok {
|
||||
return CoinSymbol{}, ErrUnsupportedCoin
|
||||
return CoinSymbol{Symbol: symbol, CoinGeckoID: knownCoinGeckoIDs[symbol]}, nil
|
||||
}
|
||||
|
||||
func validCoinSymbol(symbol string) bool {
|
||||
if symbol == "" || len(symbol) > maxCoinSymbolLength {
|
||||
return false
|
||||
}
|
||||
return coin, nil
|
||||
hasLetter := false
|
||||
for _, r := range symbol {
|
||||
if r > unicode.MaxASCII || (!unicode.IsLetter(r) && !unicode.IsDigit(r)) {
|
||||
return false
|
||||
}
|
||||
hasLetter = hasLetter || unicode.IsLetter(r)
|
||||
}
|
||||
return hasLetter
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user