refactor(stock): remove FireAnt income-events command

Drop FireAnt-based /income command per AWS decommission plan. Simplifies
stock module and reduces third-party dependencies. Updates telegram-commands.json.
This commit is contained in:
tiennm99 committed 2026-06-28 09:58:25 +07:00
1 parent a081427ae5
commit f399717613
5 files changed
-548

No files matched your search

-4
View File
@@ -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"
-2
View File
@@ -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!",
}
}
-363
View File
@@ -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 <TICKER>")
}
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
}
@@ -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)
}
}
-6
View File
@@ -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,