diff --git a/cmd/server/main.go b/cmd/server/main.go index 78d488f..7bffdff 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -90,6 +90,8 @@ func main() { exportOptionalEnv("STOCK_INCOME_EVENTS_API_TOKEN", cfg.StockIncomeEventsAPIToken) exportOptionalEnv("GOLD_PRICE_API_URL", cfg.GoldPriceAPIURL) exportOptionalEnv("GOLD_FX_API_URL", cfg.GoldFXAPIURL) + exportOptionalEnv("GOLD_VNAPP_API_URL", cfg.GoldVNAppAPIURL) + exportOptionalEnv("GOLD_VNAPP_API_KEY", cfg.GoldVNAppAPIKey) exportOptionalEnv("COIN_BINANCE_API_URL", cfg.CoinBinanceAPIURL) exportOptionalEnv("COIN_COINBASE_API_URL", cfg.CoinCoinbaseAPIURL) exportOptionalEnv("COIN_COINGECKO_API_URL", cfg.CoinCoinGeckoAPIURL) @@ -269,6 +271,8 @@ type config struct { StockIncomeEventsAPIToken string GoldPriceAPIURL string GoldFXAPIURL string + GoldVNAppAPIURL string + GoldVNAppAPIKey string CoinBinanceAPIURL string CoinCoinbaseAPIURL string CoinCoinGeckoAPIURL string @@ -282,6 +286,7 @@ type config struct { CronSecretParam string GeminiAPIKeyParam string StockIncomeEventsAPITokenParam string + GoldVNAppAPIKeyParam string } func loadConfig() config { @@ -313,6 +318,8 @@ func loadConfig() config { StockIncomeEventsAPIToken: envMap["STOCK_INCOME_EVENTS_API_TOKEN"], GoldPriceAPIURL: envMap["GOLD_PRICE_API_URL"], GoldFXAPIURL: envMap["GOLD_FX_API_URL"], + GoldVNAppAPIURL: envMap["GOLD_VNAPP_API_URL"], + GoldVNAppAPIKey: envMap["GOLD_VNAPP_API_KEY"], CoinBinanceAPIURL: envMap["COIN_BINANCE_API_URL"], CoinCoinbaseAPIURL: envMap["COIN_COINBASE_API_URL"], CoinCoinGeckoAPIURL: envMap["COIN_COINGECKO_API_URL"], @@ -326,6 +333,7 @@ func loadConfig() config { CronSecretParam: strings.TrimSpace(envMap["CRON_SHARED_SECRET_PARAMETER_NAME"]), GeminiAPIKeyParam: strings.TrimSpace(envMap["GEMINI_API_KEY_PARAMETER_NAME"]), StockIncomeEventsAPITokenParam: strings.TrimSpace(envMap["STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME"]), + GoldVNAppAPIKeyParam: strings.TrimSpace(envMap["GOLD_VNAPP_API_KEY_PARAMETER_NAME"]), } } @@ -339,6 +347,7 @@ func resolveSSMSecrets(ctx context.Context, cfg *config) error { {name: cfg.CronSecretParam, target: &cfg.CronSecret}, {name: cfg.GeminiAPIKeyParam, target: &cfg.GeminiAPIKey}, {name: cfg.StockIncomeEventsAPITokenParam, target: &cfg.StockIncomeEventsAPIToken}, + {name: cfg.GoldVNAppAPIKeyParam, target: &cfg.GoldVNAppAPIKey}, } targetsByName := map[string][]*string{} diff --git a/internal/modules/gold/composite_prices.go b/internal/modules/gold/composite_prices.go new file mode 100644 index 0000000..9ff6c59 --- /dev/null +++ b/internal/modules/gold/composite_prices.go @@ -0,0 +1,74 @@ +package gold + +import ( + "context" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/storage" +) + +// sjcPriceFetcher is the subset of VNAppMobClient needed by the composite +// fetcher. It is implemented by *VNAppMobClient and by test stubs. +type sjcPriceFetcher interface { + FetchSJCPrice(ctx context.Context) (buy, sell float64, err error) +} + +// compositePriceFetcher prefers VNAppMob SJC prices and falls back to the +// existing XAU/USD-derived chain when SJC is unavailable. +type compositePriceFetcher struct { + vnappmob sjcPriceFetcher + fallback priceFetcher +} + +// NewCompositePriceFetcherFromEnv builds the production price fetcher using +// env-driven VNAppMob and fallback clients. +func NewCompositePriceFetcherFromEnv(kv storage.KVStore) priceFetcher { + return &compositePriceFetcher{ + vnappmob: NewVNAppMobClientFromEnv(kv), + fallback: NewGoldPriceClientFromEnv(), + } +} + +// FetchLuongPrice returns a representative VND/lượng price. It uses the SJC +// mid price when available, otherwise the XAU/USD-derived spot price. +func (f *compositePriceFetcher) FetchLuongPrice(ctx context.Context) (float64, error) { + buy, sell, err := f.FetchLuongPrices(ctx) + if err != nil { + return 0, err + } + return (buy + sell) / 2, nil +} + +// FetchLuongPrices returns SJC buy/sell VND/lượng when VNAppMob is healthy, +// otherwise the XAU/USD-derived spot price for both sides. +func (f *compositePriceFetcher) FetchLuongPrices(ctx context.Context) (float64, float64, error) { + buy, sell, err := f.vnappmob.FetchSJCPrice(ctx) + if err == nil { + return buy, sell, nil + } + log.Warn("vnappmob_sjc_failed", "err", err) + p, err := f.fallback.FetchLuongPrice(ctx) + return p, p, err +} + +// FetchPrice returns a GoldPrice. When VNAppMob succeeds the struct carries +// SJC buy/sell data and Source "vnappmob-sjc"; otherwise it falls back to the +// XAU/USD chain with Source "xau-fallback". +func (f *compositePriceFetcher) FetchPrice(ctx context.Context) (GoldPrice, error) { + buy, sell, err := f.vnappmob.FetchSJCPrice(ctx) + if err == nil { + mid := (buy + sell) / 2 + return GoldPrice{ + VNDPerLuong: mid, + Source: "vnappmob-sjc", + SJC: &SJCPrice{Buy: buy, Sell: sell}, + }, nil + } + log.Warn("vnappmob_sjc_failed", "err", err) + p, err := f.fallback.FetchPrice(ctx) + if err != nil { + return GoldPrice{}, err + } + p.Source = "xau-fallback" + return p, nil +} diff --git a/internal/modules/gold/composite_prices_test.go b/internal/modules/gold/composite_prices_test.go new file mode 100644 index 0000000..fd9f051 --- /dev/null +++ b/internal/modules/gold/composite_prices_test.go @@ -0,0 +1,152 @@ +package gold + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/tiennm99/miti99bot/internal/storage" +) + +// stubVNAppMobClient implements just enough of the VNAppMob client contract +// for composite fetcher tests. +type stubVNAppMobClient struct { + buy float64 + sell float64 + err error +} + +func (s *stubVNAppMobClient) FetchSJCPrice(context.Context) (float64, float64, error) { + return s.buy, s.sell, s.err +} + +type stubFallbackFetcher struct { + price float64 + err error +} + +func (s *stubFallbackFetcher) FetchLuongPrice(context.Context) (float64, error) { + return s.price, s.err +} + +func (s *stubFallbackFetcher) FetchLuongPrices(context.Context) (float64, float64, error) { + return s.price, s.price, s.err +} + +func (s *stubFallbackFetcher) FetchPrice(context.Context) (GoldPrice, error) { + if s.err != nil { + return GoldPrice{}, s.err + } + return GoldPrice{VNDPerLuong: s.price, Source: "xau-fallback"}, nil +} + +func TestCompositeFetcher_PrefersVNAppMob(t *testing.T) { + f := &compositePriceFetcher{ + vnappmob: &stubVNAppMobClient{buy: 90_000_000, sell: 91_000_000}, + fallback: &stubFallbackFetcher{err: errors.New("fallback unavailable")}, + } + + p, err := f.FetchPrice(context.Background()) + if err != nil { + t.Fatalf("FetchPrice: %v", err) + } + if p.Source != "vnappmob-sjc" { + t.Fatalf("source: got %q, want vnappmob-sjc", p.Source) + } + if p.SJC == nil || p.SJC.Buy != 90_000_000 || p.SJC.Sell != 91_000_000 { + t.Fatalf("SJC: got %+v", p.SJC) + } + wantMid := 90_500_000.0 + if p.VNDPerLuong != wantMid { + t.Fatalf("VNDPerLuong: got %v, want %v", p.VNDPerLuong, wantMid) + } + + mid, err := f.FetchLuongPrice(context.Background()) + if err != nil { + t.Fatalf("FetchLuongPrice: %v", err) + } + if mid != wantMid { + t.Fatalf("FetchLuongPrice: got %v, want %v", mid, wantMid) + } + + buy, sell, err := f.FetchLuongPrices(context.Background()) + if err != nil { + t.Fatalf("FetchLuongPrices: %v", err) + } + if buy != 90_000_000 || sell != 91_000_000 { + t.Fatalf("FetchLuongPrices: got buy=%v sell=%v", buy, sell) + } +} + +func TestCompositeFetcher_FallsBack(t *testing.T) { + f := &compositePriceFetcher{ + vnappmob: &stubVNAppMobClient{err: errors.New("vnappmob down")}, + fallback: &stubFallbackFetcher{price: 88_000_000}, + } + p, err := f.FetchPrice(context.Background()) + if err != nil { + t.Fatalf("FetchPrice: %v", err) + } + if p.Source != "xau-fallback" { + t.Fatalf("source: got %q, want xau-fallback", p.Source) + } + if p.VNDPerLuong != 88_000_000 { + t.Fatalf("VNDPerLuong: got %v, want 88000000", p.VNDPerLuong) + } + + mid, err := f.FetchLuongPrice(context.Background()) + if err != nil { + t.Fatalf("FetchLuongPrice: %v", err) + } + if mid != 88_000_000 { + t.Fatalf("FetchLuongPrice: got %v, want 88000000", mid) + } +} + +func TestGoldPriceLines(t *testing.T) { + t.Run("sjc", func(t *testing.T) { + lines := goldPriceLines(GoldPrice{ + VNDPerLuong: 90_500_000, + Source: "vnappmob-sjc", + SJC: &SJCPrice{Buy: 90_000_000, Sell: 91_000_000}, + }) + want := []string{"Gold Spot Price (SJC)", "Buy:", "Sell:"} + if len(lines) != len(want) { + t.Fatalf("got %d lines, want %d: %v", len(lines), len(want), lines) + } + for i, w := range want { + if !strings.Contains(lines[i], w) { + t.Fatalf("line %d missing %q in %v", i, w, lines) + } + } + }) + + t.Run("fallback", func(t *testing.T) { + lines := goldPriceLines(GoldPrice{ + XAUUSD: 3000, + USDVND: 25000, + VNDPerLuong: 90_000_000, + Source: "xau-fallback", + }) + want := []string{"Gold Spot Price", "XAU:", "Rate:", "VND:"} + for i, w := range want { + if i >= len(lines) || !strings.Contains(lines[i], w) { + t.Fatalf("line %d missing %q in %v", i, w, lines) + } + } + }) +} + +func TestNewCompositePriceFetcherFromEnv(t *testing.T) { + f, ok := NewCompositePriceFetcherFromEnv(storage.NewMemoryKVStore()).(*compositePriceFetcher) + if !ok { + t.Fatalf("expected *compositePriceFetcher, got %T", f) + } + if f.vnappmob == nil { + t.Fatal("vnappmob client is nil") + } + if f.fallback == nil { + t.Fatal("fallback client is nil") + } +} diff --git a/internal/modules/gold/handlers.go b/internal/modules/gold/handlers.go index 8738348..288c0ad 100644 --- a/internal/modules/gold/handlers.go +++ b/internal/modules/gold/handlers.go @@ -27,13 +27,24 @@ func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Upda if err != nil { return s.replyPriceError(ctx, b, update, err) } - lines := []string{ + lines := goldPriceLines(p) + return chathelper.Reply(ctx, b, update.Message, strings.Join(lines, "\n")) +} + +func goldPriceLines(p GoldPrice) []string { + if p.Source == "vnappmob-sjc" && p.SJC != nil { + return []string{ + "Gold Spot Price (SJC)", + "Buy: " + FormatVND(p.SJC.Buy) + "/luong", + "Sell: " + FormatVND(p.SJC.Sell) + "/luong", + } + } + return []string{ "Gold Spot Price", "XAU: " + FormatUSD(p.XAUUSD) + " USD/oz", "Rate: " + FormatVND(p.USDVND) + "/USD", "VND: " + FormatVND(p.VNDPerLuong) + "/luong", } - return chathelper.Reply(ctx, b, update.Message, strings.Join(lines, "\n")) } func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Update) error { @@ -79,11 +90,11 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update if !ok { return chathelper.Reply(ctx, b, update.Message, "Luong must be a positive finite number.") } - price, err := s.prices.FetchLuongPrice(ctx) + _, sellPrice, err := s.prices.FetchLuongPrices(ctx) if err != nil { return s.replyPriceError(ctx, b, update, err) } - cost := qty * price + cost := qty * sellPrice if !isSafeVND(cost) { return chathelper.Reply(ctx, b, update.Message, "Trade value is too large.") } @@ -108,7 +119,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update return chathelper.Reply(ctx, b, update.Message, "Could not save gold portfolio. Try again later.") } return chathelper.Reply(ctx, b, update.Message, - "Bought "+FormatLuong(qty)+" luong gold @ "+FormatVND(price)+"/luong\nCost: "+FormatVND(cost)+ + "Bought "+FormatLuong(qty)+" luong gold @ "+FormatVND(sellPrice)+"/luong\nCost: "+FormatVND(cost)+ "\nRemaining: "+FormatVND(p.VND)) } @@ -126,13 +137,13 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat if !ok { return chathelper.Reply(ctx, b, update.Message, "Luong must be a positive finite number.") } - price, err := s.prices.FetchLuongPrice(ctx) + buyPrice, _, err := s.prices.FetchLuongPrices(ctx) if err != nil { return s.replyPriceError(ctx, b, update, err) } defer s.locks.Acquire(strconv.FormatInt(userID, 10))() - revenue := qty * price + revenue := qty * buyPrice if !isSafeVND(revenue) { return chathelper.Reply(ctx, b, update.Message, "Trade value is too large.") } @@ -155,7 +166,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat return chathelper.Reply(ctx, b, update.Message, "Could not save gold portfolio. Try again later.") } return chathelper.Reply(ctx, b, update.Message, - "Sold "+FormatLuong(qty)+" luong gold @ "+FormatVND(price)+"/luong\nRevenue: "+FormatVND(revenue)+ + "Sold "+FormatLuong(qty)+" luong gold @ "+FormatVND(buyPrice)+"/luong\nRevenue: "+FormatVND(revenue)+ "\nRemaining: "+FormatVND(p.VND)) } @@ -173,10 +184,10 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda lines := []string{"Gold Account Summary\n", "VND: " + FormatVND(p.VND), "Gold: " + FormatLuong(p.Luong) + " luong"} totalValue := p.VND - if price, err := s.prices.FetchLuongPrice(ctx); err == nil { - goldValue := p.Luong * price + if buyPrice, _, err := s.prices.FetchLuongPrices(ctx); err == nil { + goldValue := p.Luong * buyPrice totalValue += goldValue - lines = append(lines, "Price: "+FormatVND(price)+"/luong") + lines = append(lines, "Price: "+FormatVND(buyPrice)+"/luong") lines = append(lines, "Gold value: "+FormatVND(goldValue)) lines = append(lines, "Total value: "+FormatVND(totalValue)) lines = append(lines, "Invested: "+FormatVND(p.Meta.Invested)) diff --git a/internal/modules/gold/handlers_test.go b/internal/modules/gold/handlers_test.go index 8a7b107..e3cf6d1 100644 --- a/internal/modules/gold/handlers_test.go +++ b/internal/modules/gold/handlers_test.go @@ -21,6 +21,10 @@ func (f fakePriceFetcher) FetchLuongPrice(context.Context) (float64, error) { return f.price, f.err } +func (f fakePriceFetcher) FetchLuongPrices(context.Context) (float64, float64, error) { + return f.price, f.price, f.err +} + func (f fakePriceFetcher) FetchPrice(context.Context) (GoldPrice, error) { if f.err != nil { return GoldPrice{}, f.err @@ -164,6 +168,29 @@ func TestStatsWithAndWithoutPrice(t *testing.T) { rb.AssertSentText(t, "Price: no price") } +// spreadPriceFetcher returns different buy/sell prices so handler tests can +// verify that buys use the sell price and sells use the buy price. +type spreadPriceFetcher struct { + buy float64 + sell float64 + err error +} + +func (f spreadPriceFetcher) FetchLuongPrice(context.Context) (float64, error) { + return (f.buy + f.sell) / 2, f.err +} + +func (f spreadPriceFetcher) FetchLuongPrices(context.Context) (float64, float64, error) { + return f.buy, f.sell, f.err +} + +func (f spreadPriceFetcher) FetchPrice(context.Context) (GoldPrice, error) { + if f.err != nil { + return GoldPrice{}, f.err + } + return GoldPrice{XAUUSD: 3000, USDVND: 25000, VNDPerLuong: (f.buy + f.sell) / 2}, nil +} + func modDepsForTest() modules.Deps { return modules.Deps{KV: storage.NewMemoryKVStore()} } @@ -199,3 +226,50 @@ func TestHandlePriceFetchError(t *testing.T) { } rb.AssertSentText(t, "Could not fetch gold price") } + +func TestHandleBuyUsesSellPrice(t *testing.T) { + ctx := context.Background() + s := &state{ + kv: storage.NewMemoryKVStore(), + prices: spreadPriceFetcher{buy: 90_000_000, sell: 91_000_000}, + nowFn: func() time.Time { return time.UnixMilli(123) }, + } + rb := testutil.NewRecordingBot(t) + _ = s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_topup 500000000")) + rb.Reset() + if err := s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_buy 1")); err != nil { + t.Fatalf("buy: %v", err) + } + text := rb.LastSent().Text() + if !strings.Contains(text, "91.000.000 VND/luong") { + t.Fatalf("expected sell price 91.000.000 in %q", text) + } + p, _ := LoadPortfolio(ctx, s.kv, 7, 999) + if p.VND != 409_000_000 { + t.Fatalf("balance after buy at 91M: got %v, want 409000000", p.VND) + } +} + +func TestHandleSellUsesBuyPrice(t *testing.T) { + ctx := context.Background() + s := &state{ + kv: storage.NewMemoryKVStore(), + prices: spreadPriceFetcher{buy: 90_000_000, sell: 91_000_000}, + nowFn: func() time.Time { return time.UnixMilli(123) }, + } + rb := testutil.NewRecordingBot(t) + _ = s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_topup 500000000")) + _ = s.handleBuy(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_buy 1")) + rb.Reset() + if err := s.handleSell(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/gold_sell 1")); err != nil { + t.Fatalf("sell: %v", err) + } + text := rb.LastSent().Text() + if !strings.Contains(text, "90.000.000 VND/luong") { + t.Fatalf("expected buy price 90.000.000 in %q", text) + } + p, _ := LoadPortfolio(ctx, s.kv, 7, 999) + if p.VND != 499_000_000 { + t.Fatalf("balance after sell at 90M: got %v, want 499000000", p.VND) + } +} diff --git a/internal/modules/gold/helpers.go b/internal/modules/gold/helpers.go index c4d7dd5..edeb379 100644 --- a/internal/modules/gold/helpers.go +++ b/internal/modules/gold/helpers.go @@ -19,6 +19,7 @@ import ( type priceFetcher interface { FetchLuongPrice(ctx context.Context) (float64, error) + FetchLuongPrices(ctx context.Context) (buy, sell float64, err error) FetchPrice(ctx context.Context) (GoldPrice, error) } @@ -30,7 +31,7 @@ type state struct { } func newState(kv storage.KVStore) *state { - return &state{kv: kv, prices: NewGoldPriceClientFromEnv()} + return &state{kv: kv, prices: NewCompositePriceFetcherFromEnv(kv)} } func (s *state) now() time.Time { diff --git a/internal/modules/gold/prices.go b/internal/modules/gold/prices.go index e374c2f..934c35a 100644 --- a/internal/modules/gold/prices.go +++ b/internal/modules/gold/prices.go @@ -27,6 +27,14 @@ type GoldPrice struct { XAUUSD float64 USDVND float64 VNDPerLuong float64 + Source string // "vnappmob-sjc" or "xau-fallback" + SJC *SJCPrice // non-nil when Source == "vnappmob-sjc" +} + +// SJCPrice holds VNAppMob SJC buy/sell quotes per lượng (VND). +type SJCPrice struct { + Buy float64 + Sell float64 } // GoldPriceClient fetches XAU/USD through a chain of free providers (see @@ -80,6 +88,13 @@ func (c *GoldPriceClient) FetchLuongPrice(ctx context.Context) (float64, error) return p.VNDPerLuong, nil } +// FetchLuongPrices returns the same representative spot price for both buy and +// sell because the XAU/USD fallback has no bid/ask spread. +func (c *GoldPriceClient) FetchLuongPrices(ctx context.Context) (float64, float64, error) { + p, err := c.FetchLuongPrice(ctx) + return p, p, err +} + func (c *GoldPriceClient) httpClient() *http.Client { if c.HTTP != nil { return c.HTTP diff --git a/internal/modules/gold/vnappmob_client.go b/internal/modules/gold/vnappmob_client.go new file mode 100644 index 0000000..a0bd58c --- /dev/null +++ b/internal/modules/gold/vnappmob_client.go @@ -0,0 +1,291 @@ +package gold + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/storage" +) + +const ( + vnappmobDefaultURL = "https://api.vnappmob.com" + vnappmobKeyCacheKey = "vnappmob:api_key" + vnappmobRefreshBuffer = 24 * time.Hour + vnappmobHTTPTimeout = 10 * time.Second +) + +// VNAppMobClient fetches Vietnam SJC gold prices from api.vnappmob.com. +// It self-manages a free JWT API key, caching it in KV and refreshing it +// before expiry or when the SJC endpoint returns 403. +type VNAppMobClient struct { + HTTP *http.Client + BaseURL string // optional override; default https://api.vnappmob.com + Token string // optional env override (GOLD_VNAPP_API_KEY) + KV storage.KVStore // module-scoped KV store + + nowFn func() time.Time + mu sync.Mutex +} + +// NewVNAppMobClientFromEnv creates a client reading GOLD_VNAPP_API_URL and +// GOLD_VNAPP_API_KEY from the environment. The API key env var is intended +// for local dev or SSM injection; when empty the client refreshes the key +// automatically via the VNAppMob refresh endpoint. +func NewVNAppMobClientFromEnv(kv storage.KVStore) *VNAppMobClient { + return &VNAppMobClient{ + BaseURL: strings.TrimSpace(os.Getenv("GOLD_VNAPP_API_URL")), + Token: strings.TrimSpace(os.Getenv("GOLD_VNAPP_API_KEY")), + KV: kv, + } +} + +func (c *VNAppMobClient) now() time.Time { + if c.nowFn != nil { + return c.nowFn() + } + return time.Now() +} + +func (c *VNAppMobClient) baseURL() string { + if s := strings.TrimSpace(c.BaseURL); s != "" { + return strings.TrimRight(s, "/") + } + return vnappmobDefaultURL +} + +func (c *VNAppMobClient) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: vnappmobHTTPTimeout} +} + +// FetchSJCPrice returns the VNAppMob SJC buy/sell price per lượng in VND. +// On 403 it refreshes the API key once and retries. +func (c *VNAppMobClient) FetchSJCPrice(ctx context.Context) (buy, sell float64, err error) { + key, err := c.getKey(ctx) + if err != nil { + return 0, 0, fmt.Errorf("vnappmob: get key: %w", err) + } + + buy, sell, err = c.fetchSJC(ctx, key) + if err == nil { + return buy, sell, nil + } + var statusErr *httpStatusError + if !errors.As(err, &statusErr) || (statusErr.StatusCode != http.StatusForbidden && statusErr.StatusCode != http.StatusUnauthorized) { + return 0, 0, err + } + + log.Warn("vnappmob_sjc_auth_failed", "status", statusErr.StatusCode, "msg", "refreshing key after auth failure") + if refreshErr := c.refreshKey(ctx); refreshErr != nil { + return 0, 0, fmt.Errorf("vnappmob: 403 refresh failed: %w", refreshErr) + } + key, err = c.getKey(ctx) + if err != nil { + return 0, 0, fmt.Errorf("vnappmob: get key after refresh: %w", err) + } + return c.fetchSJC(ctx, key) +} + +func (c *VNAppMobClient) fetchSJC(ctx context.Context, key string) (buy, sell float64, err error) { + endpoint := c.baseURL() + "/api/v2/gold/sjc" + if err := validateEndpoint(endpoint); err != nil { + return 0, 0, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return 0, 0, fmt.Errorf("vnappmob: build request: %w", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)") + req.Header.Set("Authorization", "Bearer "+key) + + resp, err := c.httpClient().Do(req) + if err != nil { + return 0, 0, fmt.Errorf("vnappmob: SJC request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 0, 0, &httpStatusError{ + StatusCode: resp.StatusCode, + msg: fmt.Sprintf("vnappmob: SJC status %d", resp.StatusCode), + } + } + + var body struct { + Results []struct { + Buy1L json.Number `json:"buy_1l"` + Sell1L json.Number `json:"sell_1l"` + } `json:"results"` + } + dec := json.NewDecoder(resp.Body) + dec.UseNumber() + if err := dec.Decode(&body); err != nil { + return 0, 0, fmt.Errorf("vnappmob: SJC decode: %w", err) + } + if len(body.Results) == 0 { + return 0, 0, ErrNoGoldPrice + } + r := body.Results[0] + buy, err = r.Buy1L.Float64() + if err != nil { + return 0, 0, ErrNoGoldPrice + } + sell, err = r.Sell1L.Float64() + if err != nil { + return 0, 0, ErrNoGoldPrice + } + if buy <= 0 || sell <= 0 || math.IsNaN(buy) || math.IsNaN(sell) || math.IsInf(buy, 0) || math.IsInf(sell, 0) { + return 0, 0, ErrNoGoldPrice + } + return buy, sell, nil +} + +type httpStatusError struct { + StatusCode int + msg string +} + +func (e *httpStatusError) Error() string { return e.msg } + +// getKey returns a valid API key, refreshing from KV or the remote endpoint +// when the current key is missing or close to expiry. +func (c *VNAppMobClient) getKey(ctx context.Context) (string, error) { + if c.Token != "" { + return c.Token, nil + } + + c.mu.Lock() + defer c.mu.Unlock() + + var cached struct { + Token string `json:"token"` + Exp int64 `json:"exp"` + } + if err := c.KV.GetJSON(ctx, vnappmobKeyCacheKey, &cached); err == nil { + if cached.Token != "" && !c.isExpired(cached.Exp) { + return cached.Token, nil + } + } else if !errors.Is(err, storage.ErrNotFound) { + log.Warn("vnappmob_kv_read_failed", "err", err) + } + + if err := c.refreshKeyLocked(ctx); err != nil { + return "", err + } + + // Re-read from KV to get the freshly stored key. + if err := c.KV.GetJSON(ctx, vnappmobKeyCacheKey, &cached); err != nil { + return "", fmt.Errorf("vnappmob: read refreshed key: %w", err) + } + if cached.Token == "" { + return "", fmt.Errorf("vnappmob: refreshed key is empty") + } + return cached.Token, nil +} + +// refreshKey acquires a new JWT from VNAppMob and stores it in KV. +// It is safe to call concurrently; last-write-wins is acceptable because all +// callers receive a valid key. +func (c *VNAppMobClient) refreshKey(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + return c.refreshKeyLocked(ctx) +} + +func (c *VNAppMobClient) refreshKeyLocked(ctx context.Context) error { + endpoint := c.baseURL() + "/api/request_api_key?scope=gold" + if err := validateEndpoint(endpoint); err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) + if err != nil { + return fmt.Errorf("vnappmob: build refresh request: %w", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)") + + resp, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("vnappmob: refresh request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("vnappmob: refresh status %d", resp.StatusCode) + } + + var token string + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("vnappmob: refresh read body: %w", err) + } + // The endpoint may return either a JSON-quoted string or a raw JWT. + token = strings.TrimSpace(string(body)) + var quoted string + if err := json.Unmarshal(body, "ed); err == nil { + token = strings.TrimSpace(quoted) + } + if token == "" { + return fmt.Errorf("vnappmob: refresh returned empty token") + } + + exp, err := jwtExp(token) + if err != nil { + log.Warn("vnappmob_jwt_parse_failed", "err", err) + // Store anyway; next call will refresh if expiry cannot be verified. + } + + if err := c.KV.PutJSON(ctx, vnappmobKeyCacheKey, struct { + Token string `json:"token"` + Exp int64 `json:"exp"` + }{Token: token, Exp: exp}); err != nil { + return fmt.Errorf("vnappmob: cache key: %w", err) + } + log.Info("vnappmob_key_refreshed", "exp", exp) + return nil +} + +// jwtExp extracts the exp claim from a JWT's payload segment using only the +// standard library. It does not verify the signature. +func jwtExp(token string) (int64, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return 0, fmt.Errorf("vnappmob: JWT has %d parts, want 3", len(parts)) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return 0, fmt.Errorf("vnappmob: decode JWT payload: %w", err) + } + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return 0, fmt.Errorf("vnappmob: unmarshal JWT claims: %w", err) + } + if claims.Exp == 0 { + return 0, fmt.Errorf("vnappmob: JWT missing exp claim") + } + return claims.Exp, nil +} + +// isExpired reports whether a key expiring at exp (Unix seconds) should be +// refreshed now. A 24-hour buffer avoids midnight edge cases and clock skew. +func (c *VNAppMobClient) isExpired(exp int64) bool { + if exp == 0 { + return true + } + return c.now().After(time.Unix(exp, 0).Add(-vnappmobRefreshBuffer)) +} diff --git a/internal/modules/gold/vnappmob_client_test.go b/internal/modules/gold/vnappmob_client_test.go new file mode 100644 index 0000000..fc62454 --- /dev/null +++ b/internal/modules/gold/vnappmob_client_test.go @@ -0,0 +1,265 @@ +package gold + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/tiennm99/miti99bot/internal/storage" +) + +func newTestVNAppMobClient(srv *httptest.Server, kv storage.KVStore) *VNAppMobClient { + return &VNAppMobClient{ + HTTP: srv.Client(), + BaseURL: srv.URL, + KV: kv, + nowFn: func() time.Time { return time.Unix(1000, 0) }, + } +} + +func makeJWT(exp int64) string { + header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"}) + payload, _ := json.Marshal(map[string]any{"exp": exp, "scope": "gold", "permission": "read"}) + h := base64.RawURLEncoding.EncodeToString(header) + p := base64.RawURLEncoding.EncodeToString(payload) + return h + "." + p + ".dummy-signature" +} + +func TestJWTExp(t *testing.T) { + now := time.Unix(1000, 0) + tests := []struct { + name string + token string + wantExp int64 + wantErr bool + }{ + { + name: "valid", + token: makeJWT(now.Add(48 * time.Hour).Unix()), + wantExp: now.Add(48 * time.Hour).Unix(), + }, + { + name: "missing exp", + token: makeJWT(0), + wantErr: true, + }, + { + name: "malformed", + token: "not-a-jwt", + wantErr: true, + }, + { + name: "bad base64 payload", + token: "header.!!!.sig", + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := jwtExp(tc.token) + if tc.wantErr { + if err == nil { + t.Fatalf("want error, got exp=%d", got) + } + return + } + if err != nil { + t.Fatalf("jwtExp: %v", err) + } + if got != tc.wantExp { + t.Fatalf("exp: got %d, want %d", got, tc.wantExp) + } + }) + } +} + +func TestIsExpired(t *testing.T) { + now := time.Unix(1000, 0) + c := &VNAppMobClient{nowFn: func() time.Time { return now }} + + if !c.isExpired(0) { + t.Fatal("exp=0 should be expired") + } + if !c.isExpired(now.Add(12 * time.Hour).Unix()) { + t.Fatal("key expiring in 12h should be expired (within 24h buffer)") + } + if c.isExpired(now.Add(48 * time.Hour).Unix()) { + t.Fatal("key expiring in 48h should not be expired") + } +} + +func TestRefreshKey(t *testing.T) { + exp := time.Unix(1000, 0).Add(14 * 24 * time.Hour).Unix() + var refreshHits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("refresh: want POST, got %s", r.Method) + } + if !strings.HasSuffix(r.URL.Path, "/api/request_api_key") { + t.Errorf("refresh path: got %s", r.URL.Path) + } + if scope := r.URL.Query().Get("scope"); scope != "gold" { + t.Errorf("refresh scope: got %q", scope) + } + atomic.AddInt32(&refreshHits, 1) + fmt.Fprint(w, makeJWT(exp)) + })) + defer srv.Close() + + kv := storage.NewMemoryKVStore() + c := newTestVNAppMobClient(srv, kv) + + key, err := c.getKey(context.Background()) + if err != nil { + t.Fatalf("getKey: %v", err) + } + if key == "" { + t.Fatal("expected non-empty key") + } + if atomic.LoadInt32(&refreshHits) != 1 { + t.Fatalf("refresh hits: got %d, want 1", refreshHits) + } + + // A second call should reuse the cached key without hitting refresh again. + key2, err := c.getKey(context.Background()) + if err != nil { + t.Fatalf("getKey cached: %v", err) + } + if key2 != key { + t.Fatal("cached key changed unexpectedly") + } + if atomic.LoadInt32(&refreshHits) != 1 { + t.Fatalf("refresh hits after cache: got %d, want 1", refreshHits) + } +} + +func TestFetchSJCPrice(t *testing.T) { + exp := time.Unix(1000, 0).Add(14 * 24 * time.Hour).Unix() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/request_api_key": + fmt.Fprint(w, makeJWT(exp)) + case "/api/v2/gold/sjc": + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + t.Errorf("missing bearer token, got %q", auth) + } + _, _ = w.Write([]byte(`{"results":[{"buy_1l":"90000000.0","sell_1l":"91000000.0"}]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + kv := storage.NewMemoryKVStore() + c := newTestVNAppMobClient(srv, kv) + buy, sell, err := c.FetchSJCPrice(context.Background()) + if err != nil { + t.Fatalf("FetchSJCPrice: %v", err) + } + if buy != 90_000_000 || sell != 91_000_000 { + t.Fatalf("price: got buy=%v sell=%v", buy, sell) + } +} + +func TestFetchSJCPrice_401Or403Refreshes(t *testing.T) { + exp := time.Unix(1000, 0).Add(14 * 24 * time.Hour).Unix() + for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { + t.Run(fmt.Sprintf("status_%d", status), func(t *testing.T) { + var sjcHits, refreshHits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/request_api_key": + atomic.AddInt32(&refreshHits, 1) + fmt.Fprint(w, makeJWT(exp)) + case "/api/v2/gold/sjc": + n := atomic.AddInt32(&sjcHits, 1) + if n == 1 { + w.WriteHeader(status) + return + } + _, _ = w.Write([]byte(`{"results":[{"buy_1l":"90000000.0","sell_1l":"91000000.0"}]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + c := newTestVNAppMobClient(srv, storage.NewMemoryKVStore()) + buy, sell, err := c.FetchSJCPrice(context.Background()) + if err != nil { + t.Fatalf("FetchSJCPrice: %v", err) + } + if buy != 90_000_000 || sell != 91_000_000 { + t.Fatalf("price: got buy=%v sell=%v", buy, sell) + } + if atomic.LoadInt32(&sjcHits) != 2 { + t.Fatalf("SJC hits: got %d, want 2", sjcHits) + } + // First refresh gets the initial key; the auth failure triggers a second refresh. + if atomic.LoadInt32(&refreshHits) != 2 { + t.Fatalf("refresh hits: got %d, want 2", refreshHits) + } + }) + } +} + +func TestFetchSJCPrice_FallbackError(t *testing.T) { + exp := time.Unix(1000, 0).Add(14 * 24 * time.Hour).Unix() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/request_api_key": + fmt.Fprint(w, makeJWT(exp)) + case "/api/v2/gold/sjc": + _, _ = w.Write([]byte(`{"results":[]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + kv := storage.NewMemoryKVStore() + c := newTestVNAppMobClient(srv, kv) + _, _, err := c.FetchSJCPrice(context.Background()) + if !errors.Is(err, ErrNoGoldPrice) { + t.Fatalf("got %v, want ErrNoGoldPrice", err) + } +} + +func TestFetchSJCPrice_InvalidValues(t *testing.T) { + exp := time.Unix(1000, 0).Add(14 * 24 * time.Hour).Unix() + cases := []struct { + name string + body string + }{ + {name: "zero values", body: `{"results":[{"buy_1l":0,"sell_1l":0}]}`}, + {name: "missing fields", body: `{"results":[{"buy_1l":90000000}]}`}, + {name: "nan values", body: `{"results":[{"buy_1l":"NaN","sell_1l":"NaN"}]}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/request_api_key": + fmt.Fprint(w, makeJWT(exp)) + case "/api/v2/gold/sjc": + _, _ = w.Write([]byte(tc.body)) + } + })) + defer srv.Close() + c := newTestVNAppMobClient(srv, storage.NewMemoryKVStore()) + _, _, err := c.FetchSJCPrice(context.Background()) + if err == nil { + t.Fatal("expected error for invalid values") + } + }) + } +} diff --git a/template.yaml b/template.yaml index 2ab5655..341f4ba 100644 --- a/template.yaml +++ b/template.yaml @@ -47,6 +47,16 @@ Parameters: Default: "" Description: Optional USD/VND FX API URL override. Empty uses the built-in ExchangeRate-API open endpoint. + GoldVNAppAPIURL: + Type: String + Default: "" + Description: Optional VNAppMob API base URL override. Empty uses https://api.vnappmob.com. + + GoldVNAppAPIKeyParameterName: + Type: String + Default: "" + Description: Optional SSM SecureString parameter name containing a pre-issued VNAppMob JWT API key. + CoinBinanceAPIURL: Type: String Default: "" @@ -169,6 +179,8 @@ Resources: STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME: !Ref StockIncomeEventsAPITokenParameterName GOLD_PRICE_API_URL: !Ref GoldPriceAPIURL GOLD_FX_API_URL: !Ref GoldFXAPIURL + GOLD_VNAPP_API_URL: !Ref GoldVNAppAPIURL + GOLD_VNAPP_API_KEY_PARAMETER_NAME: !Ref GoldVNAppAPIKeyParameterName COIN_BINANCE_API_URL: !Ref CoinBinanceAPIURL COIN_COINBASE_API_URL: !Ref CoinCoinbaseAPIURL COIN_COINGECKO_API_URL: !Ref CoinCoinGeckoAPIURL