diff --git a/aws/telegram-commands.json b/aws/telegram-commands.json index df4a742..8145141 100644 --- a/aws/telegram-commands.json +++ b/aws/telegram-commands.json @@ -92,10 +92,6 @@ "command": "stock_income_vnd", "description": "Record cash dividend per share" }, - { - "command": "stock_income_events", - "description": "Check recent income events" - }, { "command": "stock_convert", "description": "Currency exchange" diff --git a/internal/modules/stock/handlers.go b/internal/modules/stock/handlers.go index 705b978..324e468 100644 --- a/internal/modules/stock/handlers.go +++ b/internal/modules/stock/handlers.go @@ -22,7 +22,6 @@ import ( type state struct { kv storage.KVStore prices *PriceClient - incomeEvents *IncomeEventClient locks keylock.Map nowFn func() time.Time comingSoonMessage string // exposed for tests / future i18n @@ -40,7 +39,6 @@ func newState(kv storage.KVStore) *state { return &state{ kv: kv, prices: &PriceClient{}, - incomeEvents: NewIncomeEventClientFromEnv(), comingSoonMessage: "Crypto, gold & currency exchange coming soon!", } } diff --git a/internal/modules/stock/income_events.go b/internal/modules/stock/income_events.go deleted file mode 100644 index afd49fb..0000000 --- a/internal/modules/stock/income_events.go +++ /dev/null @@ -1,363 +0,0 @@ -package stock - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "html" - "net/http" - "net/url" - "os" - "sort" - "strings" - "sync" - "time" - - "github.com/go-telegram/bot" - "github.com/go-telegram/bot/models" - - "github.com/tiennm99/miti99bot/internal/log" - "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" -) - -const ( - fireAntIncomeEventsDefaultURL = "https://restv2.fireant.vn" - incomeEventsHTTPTimeout = 10 * time.Second - incomeEventsLookback = 30 * 24 * time.Hour -) - -type IncomeEvent struct { - Symbol string - Title string - Subtitle string - DeployDate time.Time - Link string -} - -type IncomeEventClient struct { - HTTP *http.Client - URL string - Token string - - defaultOnce sync.Once - defaultClient *http.Client -} - -func NewIncomeEventClientFromEnv() *IncomeEventClient { - url := strings.TrimSpace(os.Getenv("STOCK_INCOME_EVENTS_API_URL")) - if url == "" { - url = fireAntIncomeEventsDefaultURL - } - return &IncomeEventClient{ - URL: url, - Token: strings.TrimSpace(os.Getenv("STOCK_INCOME_EVENTS_API_TOKEN")), - } -} - -func (c *IncomeEventClient) httpClient() *http.Client { - if c.HTTP != nil { - return c.HTTP - } - c.defaultOnce.Do(func() { - c.defaultClient = &http.Client{Timeout: incomeEventsHTTPTimeout} - }) - return c.defaultClient -} - -type fireAntTimescaleMark struct { - ID string `json:"id"` - Label string `json:"label"` - Date string `json:"date"` - Title string `json:"title"` - Color string `json:"color"` -} - -var ( - ErrNoIncomeEvents = errors.New("stock: no income events") - ErrIncomeEventClientNotConfigured = errors.New("stock: income events API not configured") - ErrIncomeEventAuthRequired = errors.New("stock: income events API authentication required") -) - -func (c *IncomeEventClient) FetchRecent(ctx context.Context, ticker string, since, until time.Time) ([]IncomeEvent, error) { - ticker, err := normalizeStockSymbol(ticker) - if err != nil { - return nil, err - } - if strings.TrimSpace(c.URL) == "" { - return nil, ErrIncomeEventClientNotConfigured - } - - fullURL, err := fireAntMarksURL(c.URL, ticker, since, until) - if err != nil { - return nil, err - } - req, err := fireAntRequest(ctx, fullURL, c.Token) - if err != nil { - return nil, err - } - - resp, err := c.httpClient().Do(req) - if err != nil { - return nil, fmt.Errorf("stock: FireAnt request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - marks, err := decodeFireAntMarks(resp) - if err != nil { - return nil, err - } - events := incomeEventsFromMarks(ticker, marks, since, until) - if len(events) == 0 { - return nil, ErrNoIncomeEvents - } - return events, nil -} - -func fireAntMarksURL(baseURL, ticker string, since, until time.Time) (string, error) { - endpoint, err := url.Parse(baseURL) - if err != nil { - return "", fmt.Errorf("stock: parse FireAnt URL: %w", err) - } - if !isSafeFireAntEndpoint(endpoint) { - return "", fmt.Errorf("stock: income events API URL must be https") - } - endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/symbols/" + url.PathEscape(ticker) + "/timescale-marks" - q := endpoint.Query() - q.Set("startDate", since.Format(time.RFC3339)) - q.Set("endDate", until.Format(time.RFC3339)) - endpoint.RawQuery = q.Encode() - return endpoint.String(), nil -} - -func isSafeFireAntEndpoint(endpoint *url.URL) bool { - if endpoint.Scheme == "https" { - return true - } - if endpoint.Scheme != "http" { - return false - } - return strings.HasPrefix(endpoint.Host, "127.0.0.1:") || strings.HasPrefix(endpoint.Host, "localhost:") -} - -func fireAntRequest(ctx context.Context, fullURL, token string) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) - if err != nil { - return nil, fmt.Errorf("stock: build FireAnt request: %w", err) - } - req.Header.Set("User-Agent", "miti99bot") - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - return req, nil -} - -func decodeFireAntMarks(resp *http.Response) ([]fireAntTimescaleMark, error) { - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return nil, ErrIncomeEventAuthRequired - } - if resp.StatusCode == http.StatusNotFound { - return nil, ErrNoIncomeEvents - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("stock: FireAnt status %d", resp.StatusCode) - } - - var marks []fireAntTimescaleMark - if err := json.NewDecoder(resp.Body).Decode(&marks); err != nil { - return nil, fmt.Errorf("stock: FireAnt decode: %w", err) - } - return marks, nil -} - -func incomeEventsFromMarks(ticker string, marks []fireAntTimescaleMark, since, until time.Time) []IncomeEvent { - var out []IncomeEvent - for _, mark := range marks { - date, ok := parseFireAntDate(mark.Date) - if !ok || date.Before(since) || date.After(until) || !isIncomeEventMark(mark) { - continue - } - out = append(out, incomeEventFromMark(ticker, mark, date)) - } - sort.Slice(out, func(i, j int) bool { - return out[i].DeployDate.After(out[j].DeployDate) - }) - return out -} - -func incomeEventFromMark(ticker string, mark fireAntTimescaleMark, date time.Time) IncomeEvent { - title := cleanIncomeEventText(mark.Title) - label := cleanIncomeEventText(mark.Label) - if title == "" { - title = label - } - subtitle := "" - if label != "" && label != title { - subtitle = label - } - return IncomeEvent{Symbol: ticker, Title: title, Subtitle: subtitle, DeployDate: date} -} - -func parseFireAntDate(raw string) (time.Time, bool) { - raw = strings.TrimSpace(raw) - if raw == "" { - return time.Time{}, false - } - for _, layout := range []string{time.RFC3339, "2006-01-02", "02/01/2006"} { - parsed, err := time.Parse(layout, raw) - if err == nil { - return parsed.UTC(), true - } - } - return time.Time{}, false -} - -func isIncomeEventMark(mark fireAntTimescaleMark) bool { - text := normalizeIncomeEventSearchText(mark.Label + " " + mark.Title) - terms := []string{ - "co tuc", - "dividend", - "quyen mua", - "phat hanh co phieu", - "chia co phieu", - "bonus share", - "stock dividend", - "cash dividend", - } - for _, term := range terms { - if strings.Contains(text, term) { - return true - } - } - return false -} - -func normalizeIncomeEventSearchText(s string) string { - s = strings.ToLower(cleanIncomeEventText(s)) - replacer := strings.NewReplacer( - "à", "a", "á", "a", "ạ", "a", "ả", "a", "ã", "a", "â", "a", "ầ", "a", "ấ", "a", "ậ", "a", "ẩ", "a", "ẫ", "a", "ă", "a", "ằ", "a", "ắ", "a", "ặ", "a", "ẳ", "a", "ẵ", "a", - "è", "e", "é", "e", "ẹ", "e", "ẻ", "e", "ẽ", "e", "ê", "e", "ề", "e", "ế", "e", "ệ", "e", "ể", "e", "ễ", "e", - "ì", "i", "í", "i", "ị", "i", "ỉ", "i", "ĩ", "i", - "ò", "o", "ó", "o", "ọ", "o", "ỏ", "o", "õ", "o", "ô", "o", "ồ", "o", "ố", "o", "ộ", "o", "ổ", "o", "ỗ", "o", "ơ", "o", "ờ", "o", "ớ", "o", "ợ", "o", "ở", "o", "ỡ", "o", - "ù", "u", "ú", "u", "ụ", "u", "ủ", "u", "ũ", "u", "ư", "u", "ừ", "u", "ứ", "u", "ự", "u", "ử", "u", "ữ", "u", - "ỳ", "y", "ý", "y", "ỵ", "y", "ỷ", "y", "ỹ", "y", - "đ", "d", - ) - return replacer.Replace(s) -} - -func cleanIncomeEventText(s string) string { - return strings.Join(strings.Fields(html.UnescapeString(s)), " ") -} - -func RenderIncomeEvents(events []IncomeEvent, since, until time.Time) string { - if len(events) == 0 { - return "No recent income events from FireAnt in the last 30 days." - } - var lines []string - lines = append(lines, "Income events from FireAnt") - lines = append(lines, since.Format("02/01/2006")+" - "+until.Format("02/01/2006")) - - for _, event := range events { - line := event.Symbol + " - " + event.DeployDate.Format("02/01/2006") + ": " + event.Title - if event.Subtitle != "" { - line += "\n " + event.Subtitle - } - if event.Link != "" { - line += "\n " + event.Link - } - lines = append(lines, line) - } - return strings.Join(lines, "\n") -} - -func (s *state) handleIncomeEvents(ctx context.Context, b *bot.Bot, update *models.Update) error { - userID, ok := senderInfo(update) - if !ok { - return chathelper.Reply(ctx, b, update.Message, - "Cannot identify user - /stock_income_events needs a sender.") - } - - args := argsAfterCommand(update.Message.Text) - symbols, err := s.incomeEventSymbols(ctx, userID, args) - if err != nil { - if errors.Is(err, ErrUnknownTicker) { - ticker := "" - if len(args) > 0 { - ticker = strings.ToUpper(args[0]) - } - return chathelper.Reply(ctx, b, update.Message, "Unknown stock ticker \""+ticker+"\".") - } - log.Error("stock_income_events_symbols", "user", userID, "err", err) - return chathelper.Reply(ctx, b, update.Message, "Could not load holdings. Try again later.") - } - if len(symbols) == 0 { - return chathelper.Reply(ctx, b, update.Message, - "You don't hold any stocks yet. Usage: /stock_income_events ") - } - - until := s.now().UTC() - since := until.Add(-incomeEventsLookback) - var all []IncomeEvent - var failed []string - var notConfigured bool - for _, symbol := range symbols { - events, err := s.incomeEvents.FetchRecent(ctx, symbol, since, until) - if err != nil { - if errors.Is(err, ErrIncomeEventClientNotConfigured) { - notConfigured = true - break - } - if errors.Is(err, ErrIncomeEventAuthRequired) { - return chathelper.Reply(ctx, b, update.Message, - "FireAnt income events API requires authentication. Set STOCK_INCOME_EVENTS_API_TOKEN or STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME.") - } - if errors.Is(err, ErrNoIncomeEvents) { - continue - } - log.Error("stock_fetch_income_events", "ticker", symbol, "err", err) - failed = append(failed, symbol) - continue - } - all = append(all, events...) - } - if notConfigured { - return chathelper.Reply(ctx, b, update.Message, - "Income events API is not configured. Set STOCK_INCOME_EVENTS_API_URL or use the FireAnt default.") - } - sort.Slice(all, func(i, j int) bool { - if all[i].DeployDate.Equal(all[j].DeployDate) { - return all[i].Symbol < all[j].Symbol - } - return all[i].DeployDate.After(all[j].DeployDate) - }) - - reply := RenderIncomeEvents(all, since, until) - if len(failed) > 0 { - reply += "\nCould not fetch: " + strings.Join(failed, ", ") - } - return chathelper.Reply(ctx, b, update.Message, reply) -} - -func (s *state) incomeEventSymbols(ctx context.Context, userID int64, args []string) ([]string, error) { - if len(args) > 0 { - symbol, err := normalizeStockSymbol(args[0]) - if err != nil { - return nil, err - } - return []string{symbol}, nil - } - - p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli()) - if err != nil { - return nil, err - } - var symbols []string - for symbol, qty := range p.Assets { - if qty > 0 { - symbols = append(symbols, symbol) - } - } - sort.Strings(symbols) - return symbols, nil -} diff --git a/internal/modules/stock/income_events_test.go b/internal/modules/stock/income_events_test.go deleted file mode 100644 index c73d178..0000000 --- a/internal/modules/stock/income_events_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package stock - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/tiennm99/miti99bot/internal/modules" - "github.com/tiennm99/miti99bot/internal/storage" - "github.com/tiennm99/miti99bot/internal/testutil" -) - -func newTestIncomeEventClient(t *testing.T, handler http.HandlerFunc) (*IncomeEventClient, *httptest.Server) { - t.Helper() - srv := httptest.NewServer(handler) - t.Cleanup(srv.Close) - return &IncomeEventClient{ - HTTP: srv.Client(), - URL: srv.URL, - }, srv -} - -func TestIncomeEventClient_FetchRecentUsesFireAntTimescaleMarks(t *testing.T) { - now := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC) - c, _ := newTestIncomeEventClient(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/symbols/TCX/timescale-marks" { - t.Errorf("path = %q, want /symbols/TCX/timescale-marks", r.URL.Path) - } - if got := r.URL.Query().Get("startDate"); got != "2026-05-06T00:00:00Z" { - t.Errorf("startDate query = %q, want 2026-05-06T00:00:00Z", got) - } - if got := r.URL.Query().Get("endDate"); got != "2026-06-05T00:00:00Z" { - t.Errorf("endDate query = %q, want 2026-06-05T00:00:00Z", got) - } - if got := r.Header.Get("Authorization"); got != "Bearer test-token" { - t.Errorf("authorization header = %q, want bearer token", got) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"id":"1","label":"Cổ tức","date":"2026-05-25T00:00:00Z","title":"TCX: dividend"},{"id":"2","label":"BCTC","date":"2026-05-24T00:00:00Z","title":"Financial report"},{"id":"3","label":"Cổ tức","date":"2025-06-12T00:00:00Z","title":"Old dividend"}]`)) - }) - c.Token = "test-token" - - got, err := c.FetchRecent(context.Background(), "tcx", now.Add(-incomeEventsLookback), now) - if err != nil { - t.Fatalf("FetchRecent: %v", err) - } - if len(got) != 1 { - t.Fatalf("events = %d, want 1: %+v", len(got), got) - } - if got[0].Symbol != "TCX" { - t.Errorf("symbol = %q, want TCX", got[0].Symbol) - } - if got[0].Subtitle != "Cổ tức" { - t.Errorf("subtitle = %q, want Cổ tức", got[0].Subtitle) - } -} - -func TestIncomeEventClient_FiltersNonIncomeMarks(t *testing.T) { - now := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC) - c, _ := newTestIncomeEventClient(t, func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"id":"1","label":"BCTC","date":"2026-05-25T00:00:00Z","title":"Financial report"},{"id":"2","label":"GDKHQ","date":"2026-05-24T00:00:00Z","title":"Ngày đăng ký cuối cùng trả cổ tức"},{"id":"3","label":"GDKHQ","date":"2026-05-23T00:00:00Z","title":"Ngày đăng ký cuối cùng tham dự Đại hội đồng cổ đông"}]`)) - }) - - got, err := c.FetchRecent(context.Background(), "TCX", now.Add(-incomeEventsLookback), now) - if err != nil { - t.Fatalf("FetchRecent: %v", err) - } - if len(got) != 1 || got[0].Title != "Ngày đăng ký cuối cùng trả cổ tức" { - t.Fatalf("events = %+v, want only income mark", got) - } -} - -func TestIncomeEventClient_RejectsNonHTTPSRemoteURL(t *testing.T) { - c := &IncomeEventClient{URL: "http://official.example/events", Token: "secret"} - _, err := c.FetchRecent(context.Background(), "TCX", time.Now().Add(-incomeEventsLookback), time.Now()) - if err == nil || !strings.Contains(err.Error(), "must be https") { - t.Fatalf("FetchRecent error = %v, want https requirement", err) - } -} - -func TestRenderIncomeEvents(t *testing.T) { - now := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC) - got := RenderIncomeEvents([]IncomeEvent{ - { - Symbol: "TCX", - Title: "TCX: dividend", - Subtitle: "stock dividend", - DeployDate: time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC), - Link: "", - }, - }, now.Add(-incomeEventsLookback), now) - for _, want := range []string{ - "Income events from FireAnt", - "06/05/2026 - 05/06/2026", - "TCX - 25/05/2026: TCX: dividend", - "stock dividend", - } { - if !strings.Contains(got, want) { - t.Errorf("missing %q in:\n%s", want, got) - } - } -} - -func installTradingIncomeEvents(t *testing.T, eventBody string, now time.Time) (*testutil.RecordingBot, storage.KVStore) { - t.Helper() - eventsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(eventBody)) - })) - t.Cleanup(eventsSrv.Close) - - priceSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"data":{"stockSymbol":"TCX","matchedPrice":24500}}`)) - })) - t.Cleanup(priceSrv.Close) - - rb := testutil.NewRecordingBot(t) - kv := storage.NewMemoryKVStore() - s := &state{ - kv: kv, - prices: &PriceClient{HTTP: priceSrv.Client(), URL: priceSrv.URL}, - incomeEvents: &IncomeEventClient{HTTP: eventsSrv.Client(), URL: eventsSrv.URL}, - nowFn: func() time.Time { return now }, - } - cmd := modules.Command{ - Name: "stock_income_events", - Visibility: modules.VisibilityPublic, - Description: "x", - Handler: s.handleIncomeEvents, - } - reg := &modules.Registry{ - Modules: []modules.Module{{Name: "stock", Commands: []modules.Command{cmd}}}, - AllCommands: map[string]modules.Command{cmd.Name: cmd}, - } - modules.Install(rb.Bot, reg, modules.Auth{}) - return rb, kv -} - -func TestHandleIncomeEvents_WithTicker(t *testing.T) { - now := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC) - body := `[{"id":"1","label":"GDKHQ","date":"2026-05-25T00:00:00Z","title":"TCX: 25.5.2026, ngày GDKHQ trả cổ tức bằng cổ phiếu năm 2024 (tỷ lệ 5:1)"}]` - rb, _ := installTradingIncomeEvents(t, body, now) - - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/stock_income_events TCX")) - got := rb.LastSent().Text() - for _, want := range []string{"Income events from FireAnt", "TCX - 25/05/2026", "trả cổ tức"} { - if !strings.Contains(got, want) { - t.Errorf("missing %q in:\n%s", want, got) - } - } -} - -func TestHandleIncomeEvents_UsesHoldingsWhenTickerMissing(t *testing.T) { - now := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC) - body := `[{"id":"1","label":"Cổ tức","date":"2026-05-25T00:00:00Z","title":"Holding event"}]` - rb, kv := installTradingIncomeEvents(t, body, now) - p := NewPortfolio(now.UnixMilli()) - p.AddAsset("TCX", 100) - if err := SavePortfolio(context.Background(), kv, 7, p); err != nil { - t.Fatalf("SavePortfolio: %v", err) - } - - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/stock_income_events")) - got := rb.LastSent().Text() - if !strings.Contains(got, "TCX - 25/05/2026: Holding event") { - t.Errorf("expected holding event reply; got:\n%s", got) - } -} diff --git a/internal/modules/stock/stock.go b/internal/modules/stock/stock.go index e9e2f5c..e447885 100644 --- a/internal/modules/stock/stock.go +++ b/internal/modules/stock/stock.go @@ -41,12 +41,6 @@ func New(deps modules.Deps) modules.Module { Description: "Record cash dividend (VND per share)", Handler: s.handleIncomeVND, }, - { - Name: "stock_income_events", - Visibility: modules.VisibilityPublic, - Description: "Check recent income events from FireAnt", - Handler: s.handleIncomeEvents, - }, { Name: "stock_convert", Visibility: modules.VisibilityPublic,