mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 02:16:31 +00:00
feat(gold): add /gold_price command showing spot price in USD and VND
Read-only price lookup. Displays XAU USD/oz, USD/VND rate, and VND/luong. No arguments, no portfolio mutation, no keylock needed.
This commit is contained in:
1 parent
d1a01830ae
commit
2501deb4f8
9 files changed
+234
-8
No files matched your search
@@ -49,6 +49,13 @@ func FormatPnL(currentValue, invested float64) string {
|
||||
return sign + FormatVND(diff) + " (" + sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%)"
|
||||
}
|
||||
|
||||
func FormatUSD(n float64) string {
|
||||
if math.IsNaN(n) || math.IsInf(n, 0) {
|
||||
return "invalid USD"
|
||||
}
|
||||
return "$" + strconv.FormatFloat(n, 'f', 2, 64)
|
||||
}
|
||||
|
||||
func absInt64(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
|
||||
@@ -8,6 +8,12 @@ func New(deps modules.Deps) modules.Module {
|
||||
s := newState(deps.KV)
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "gold_price",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show current gold spot price (USD & VND)",
|
||||
Handler: s.handlePrice,
|
||||
},
|
||||
{
|
||||
Name: "gold_topup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
|
||||
@@ -12,6 +12,24 @@ import (
|
||||
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
|
||||
)
|
||||
|
||||
func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
args := argsAfterCommand(update.Message.Text)
|
||||
if len(args) != 0 {
|
||||
return chathelper.Reply(ctx, b, update.Message, "Usage: /gold_price")
|
||||
}
|
||||
p, err := s.prices.FetchPrice(ctx)
|
||||
if err != nil {
|
||||
return s.replyPriceError(ctx, b, update, err)
|
||||
}
|
||||
lines := []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 {
|
||||
userID, ok := senderInfo(update)
|
||||
if !ok {
|
||||
|
||||
@@ -21,6 +21,13 @@ func (f fakePriceFetcher) FetchLuongPrice(context.Context) (float64, error) {
|
||||
return f.price, f.err
|
||||
}
|
||||
|
||||
func (f fakePriceFetcher) FetchPrice(context.Context) (GoldPrice, error) {
|
||||
if f.err != nil {
|
||||
return GoldPrice{}, f.err
|
||||
}
|
||||
return GoldPrice{XAUUSD: 3000, USDVND: 25000, VNDPerLuong: f.price}, nil
|
||||
}
|
||||
|
||||
func newTestState(price float64, err error) *state {
|
||||
return &state{
|
||||
kv: storage.NewMemoryKVStore(),
|
||||
@@ -47,7 +54,7 @@ func TestModuleRegistersExpectedCommands(t *testing.T) {
|
||||
for _, cmd := range mod.Commands {
|
||||
got[cmd.Name] = true
|
||||
}
|
||||
for _, name := range []string{"gold_topup", "gold_buy", "gold_sell", "gold_stats"} {
|
||||
for _, name := range []string{"gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_stats"} {
|
||||
if !got[name] {
|
||||
t.Fatalf("missing command %s", name)
|
||||
}
|
||||
@@ -160,3 +167,35 @@ func TestStatsWithAndWithoutPrice(t *testing.T) {
|
||||
func modDepsForTest() modules.Deps {
|
||||
return modules.Deps{KV: storage.NewMemoryKVStore()}
|
||||
}
|
||||
|
||||
func TestHandlePrice(t *testing.T) {
|
||||
s := newTestState(90_000_000, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/gold_price")); err != nil {
|
||||
t.Fatalf("handlePrice: %v", err)
|
||||
}
|
||||
text := rb.LastSent().Text()
|
||||
for _, want := range []string{"Gold Spot Price", "XAU:", "USD/oz", "VND:", "/luong"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("price missing %q in %q", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePriceRejectsArgs(t *testing.T) {
|
||||
s := newTestState(90_000_000, nil)
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/gold_price USD")); err != nil {
|
||||
t.Fatalf("handlePrice: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Usage: /gold_price")
|
||||
}
|
||||
|
||||
func TestHandlePriceFetchError(t *testing.T) {
|
||||
s := newTestState(0, errors.New("upstream down"))
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if err := s.handlePrice(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/gold_price")); err != nil {
|
||||
t.Fatalf("handlePrice: %v", err)
|
||||
}
|
||||
rb.AssertSentText(t, "Could not fetch gold price")
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
type priceFetcher interface {
|
||||
FetchLuongPrice(ctx context.Context) (float64, error)
|
||||
FetchPrice(ctx context.Context) (GoldPrice, error)
|
||||
}
|
||||
|
||||
type state struct {
|
||||
|
||||
@@ -24,6 +24,12 @@ const (
|
||||
|
||||
var ErrNoGoldPrice = errors.New("gold: no price available")
|
||||
|
||||
type GoldPrice struct {
|
||||
XAUUSD float64
|
||||
USDVND float64
|
||||
VNDPerLuong float64
|
||||
}
|
||||
|
||||
type GoldPriceClient struct {
|
||||
HTTP *http.Client
|
||||
GoldURL string
|
||||
@@ -45,20 +51,28 @@ func NewGoldPriceClientFromEnv() *GoldPriceClient {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) FetchLuongPrice(ctx context.Context) (float64, error) {
|
||||
func (c *GoldPriceClient) FetchPrice(ctx context.Context) (GoldPrice, error) {
|
||||
xauUSD, err := c.fetchXAUUSD(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return GoldPrice{}, err
|
||||
}
|
||||
usdToVND, err := c.fetchUSDVND(ctx)
|
||||
if err != nil {
|
||||
return GoldPrice{}, err
|
||||
}
|
||||
vndPerLuong := xauUSD * usdToVND * (gramsPerLuong / gramsPerTroyOunce)
|
||||
if vndPerLuong <= 0 || math.IsNaN(vndPerLuong) || math.IsInf(vndPerLuong, 0) {
|
||||
return GoldPrice{}, ErrNoGoldPrice
|
||||
}
|
||||
return GoldPrice{XAUUSD: xauUSD, USDVND: usdToVND, VNDPerLuong: vndPerLuong}, nil
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) FetchLuongPrice(ctx context.Context) (float64, error) {
|
||||
p, err := c.FetchPrice(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
price := xauUSD * usdToVND * (gramsPerLuong / gramsPerTroyOunce)
|
||||
if price <= 0 || math.IsNaN(price) || math.IsInf(price, 0) {
|
||||
return 0, ErrNoGoldPrice
|
||||
}
|
||||
return price, nil
|
||||
return p.VNDPerLuong, nil
|
||||
}
|
||||
|
||||
func (c *GoldPriceClient) httpClient() *http.Client {
|
||||
|
||||
@@ -105,6 +105,35 @@ func TestGoldPriceClient_FXRateLimitedIsRetryable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoldPriceClient_FetchPrice(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/gold":
|
||||
_, _ = w.Write([]byte(`{"items":[{"curr":"USD","xauPrice":3000}]}`))
|
||||
case "/fx":
|
||||
_, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx"}
|
||||
p, err := c.FetchPrice(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchPrice: %v", err)
|
||||
}
|
||||
if p.XAUUSD != 3000 {
|
||||
t.Errorf("XAUUSD: got %v, want 3000", p.XAUUSD)
|
||||
}
|
||||
if p.USDVND != 25000 {
|
||||
t.Errorf("USDVND: got %v, want 25000", p.USDVND)
|
||||
}
|
||||
want := 3000 * 25000 * (gramsPerLuong / gramsPerTroyOunce)
|
||||
if math.Abs(p.VNDPerLuong-want) > 0.01 {
|
||||
t.Errorf("VNDPerLuong: got %v, want %v", p.VNDPerLuong, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEndpoint(t *testing.T) {
|
||||
if err := validateEndpoint("https://example.com/path"); err != nil {
|
||||
t.Fatalf("https should pass: %v", err)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "Implement gold_price command"
|
||||
status: completed
|
||||
priority: P2
|
||||
effort: "30min"
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Implement gold_price command
|
||||
|
||||
## Overview
|
||||
|
||||
Add `/gold_price` — a read-only command that fetches and displays spot XAU in USD/oz and VND/luong. No arguments, no portfolio state, no lock needed.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: `/gold_price` with no arguments displays current gold price in USD per troy ounce and VND per luong.
|
||||
- Functional: on price fetch failure, show user-friendly error (same pattern as `replyPriceError`).
|
||||
- Non-functional: no portfolio mutation, no keylock acquisition.
|
||||
- Non-functional: reject extra arguments (`len(args) != 0`).
|
||||
|
||||
## Architecture
|
||||
|
||||
Add a `GoldPrice` struct and `FetchPrice(ctx)` method to `GoldPriceClient` that returns all three components (XAU USD, USD/VND rate, VND/luong) in one call. Reuses existing `fetchXAUUSD` and `fetchUSDVND` internals. The `priceFetcher` interface gains `FetchPrice` so tests can mock it.
|
||||
|
||||
Output format:
|
||||
```
|
||||
Gold Spot Price
|
||||
XAU: $3,285.50 USD/oz
|
||||
Rate: 25,000 VND/USD
|
||||
VND: 99,450,000 VND/luong
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `internal/modules/gold/prices.go` — add `GoldPrice` struct + `FetchPrice` method
|
||||
- Modify: `internal/modules/gold/helpers.go` — update `priceFetcher` interface
|
||||
- Modify: `internal/modules/gold/handlers.go` — add `handlePrice` handler
|
||||
- Modify: `internal/modules/gold/gold.go` — register `gold_price` command
|
||||
- Modify: `internal/modules/gold/format.go` — add `FormatUSD` helper
|
||||
- Modify: `internal/modules/gold/handlers_test.go` — add handler test
|
||||
- Modify: `internal/modules/gold/prices_test.go` — add `FetchPrice` test
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add `GoldPrice` struct to `prices.go`:
|
||||
```go
|
||||
type GoldPrice struct {
|
||||
XAUUSD float64 // USD per troy ounce
|
||||
USDVND float64 // VND per USD
|
||||
VNDPerLuong float64 // VND per luong
|
||||
}
|
||||
```
|
||||
2. Add `FetchPrice(ctx context.Context) (GoldPrice, error)` to `GoldPriceClient` — calls `fetchXAUUSD` + `fetchUSDVND`, computes VNDPerLuong, returns struct.
|
||||
3. Update `priceFetcher` interface in `helpers.go` to include `FetchPrice`.
|
||||
4. Update `fakePriceFetcher` in `handlers_test.go` to implement `FetchPrice`.
|
||||
5. Add `FormatUSD(n float64) string` to `format.go` — e.g. `$3,285.50`.
|
||||
6. Add `handlePrice` to `handlers.go`:
|
||||
- Reject if `len(args) != 0` → usage message.
|
||||
- Call `s.prices.FetchPrice(ctx)`.
|
||||
- On error → `replyPriceError`.
|
||||
- Format and reply with 3-line price summary.
|
||||
7. Register `gold_price` command in `gold.go` with description `"Show current gold spot price (USD & VND)"`.
|
||||
8. Add tests:
|
||||
- `TestHandlePrice` — verify output contains USD and VND lines.
|
||||
- `TestHandlePriceRejectsArgs` — verify extra args rejected.
|
||||
- `TestHandlePriceFetchError` — verify error path.
|
||||
- `TestGoldPriceClient_FetchPrice` — verify struct fields from httptest server.
|
||||
9. Run `go test -count=1 ./internal/modules/gold/...` and `go vet ./...`.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] `/gold_price` returns USD/oz and VND/luong prices.
|
||||
- [ ] Extra arguments rejected with usage message.
|
||||
- [ ] Price fetch errors handled gracefully.
|
||||
- [ ] `priceFetcher` interface updated; `fakePriceFetcher` implements both methods.
|
||||
- [ ] All existing + new tests pass.
|
||||
- [ ] `go vet` clean.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: "Gold price command"
|
||||
description: "Add /gold_price command showing spot XAU in USD/oz and VND/luong"
|
||||
status: completed
|
||||
priority: P2
|
||||
branch: "main"
|
||||
tags: [gold]
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: "2026-06-11T10:44:35.588Z"
|
||||
createdBy: "ck:plan"
|
||||
source: skill
|
||||
---
|
||||
|
||||
# Gold price command
|
||||
|
||||
## Overview
|
||||
|
||||
Add `/gold_price` to the gold module. Displays current spot gold price in both USD per troy ounce and VND per luong. No arguments, no portfolio mutation — read-only price lookup.
|
||||
|
||||
## Context
|
||||
|
||||
`GoldPriceClient` already fetches XAU USD/oz and USD/VND internally via unexported `fetchXAUUSD` and `fetchUSDVND`. Currently only `FetchLuongPrice` (combined VND/luong) is exposed. Need a new method returning all components.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Implement gold_price command](./phase-01-implement-gold-price-command.md) | Pending |
|
||||
|
||||
## Dependencies
|
||||
|
||||
None. Builds on committed gold module (`2304657`).
|
||||
Reference in new issue
Block a user