diff --git a/README.md b/README.md index 4ec7bbd..490262d 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,15 @@ Future commands must follow the [command parameter conventions](docs/command-parameter-conventions.md). Keep command metadata, handler usage text, tests, and documentation aligned. +### Stock corporate events + +`/stock_events [days]` lists SSI iBoard corporate actions for a VN +stock without reading or changing a portfolio. The lookback defaults to 30 +days; `days` must be a whole number from 1 to 90. Results are returned in +chronological order, split into Telegram-safe chunks when needed, and show the +raw SSI corporate-action details. The feature is best-effort because SSI's API +is undocumented. + ### Stock dividend commands Stock dividends are manual portfolio adjustments: diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index ef4e6ce..514ad56 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -69,6 +69,7 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "loldle": "[champion]", "random": "", "stats": "[users | user | cmd ]", + "stock_events": " [days]", "stock_price": "", "stock_topup": "", "stock_buy": " ", diff --git a/internal/modules/stock/dividend_events_ssi.go b/internal/modules/stock/dividend_events_ssi.go index 65f11d2..0981fa2 100644 --- a/internal/modules/stock/dividend_events_ssi.go +++ b/internal/modules/stock/dividend_events_ssi.go @@ -111,30 +111,100 @@ func (p *SSIDividendProvider) endpoint() string { // described share dividends. SSI is queried with a one-calendar-day overlap in // Asia/Saigon, then results are filtered by publication time to (after, through]. func (p *SSIDividendProvider) FetchDividendEvents(ctx context.Context, symbol string, after, through time.Time) ([]DividendEvent, error) { - symbol = strings.ToUpper(strings.TrimSpace(symbol)) - if symbol == "" { - return nil, errors.New("stock: dividend symbol is empty") - } - if after.IsZero() || through.IsZero() || !through.After(after) { - return nil, errors.New("stock: invalid dividend event range") + symbol, from, to, err := prepareSSIEventFetch( + symbol, + after, + through, + "stock: dividend symbol is empty", + "stock: invalid dividend event range", + ) + if err != nil { + return nil, err } - from := after.In(saigonLocation).AddDate(0, 0, -1) - to := through.In(saigonLocation) + rawEvents, err := p.fetchUniqueCorporateActions(ctx, symbol, from, to, "dividend") + if err != nil { + return nil, err + } lowerBound := startOfSaigonDay(after).AddDate(0, 0, -1) - seen := make(map[string]struct{}) events := make([]DividendEvent, 0) + for _, raw := range rawEvents { + event, ok := p.normalizeEvent(raw, symbol) + if !ok || event.PublishedAt.Before(lowerBound) || event.PublishedAt.After(through) { + continue + } + events = append(events, event) + } + sort.Slice(events, func(i, j int) bool { + if events[i].PublishedAt.Equal(events[j].PublishedAt) { + return events[i].ProviderID < events[j].ProviderID + } + return events[i].PublishedAt.Before(events[j].PublishedAt) + }) + return events, nil +} + +// FetchStockEvents returns all displayable SSI corporate actions. It is +// intentionally separate from FetchDividendEvents so read-only event listing +// cannot relax portfolio dividend validation. +func (p *SSIDividendProvider) FetchStockEvents(ctx context.Context, symbol string, after, through time.Time) ([]SSIStockEvent, error) { + symbol, from, to, err := prepareSSIEventFetch( + symbol, + after, + through, + "stock: event symbol is empty", + "stock: invalid stock event range", + ) + if err != nil { + return nil, err + } + + rawEvents, err := p.fetchUniqueCorporateActions(ctx, symbol, from, to, "event") + if err != nil { + return nil, err + } + events := make([]SSIStockEvent, 0) + for _, raw := range rawEvents { + event, ok := p.copyStockEvent(raw, symbol) + if !ok || !event.cursorAt.After(after) || event.cursorAt.After(through) { + continue + } + events = append(events, event) + } + sort.Slice(events, func(i, j int) bool { + if events[i].cursorAt.Equal(events[j].cursorAt) { + return events[i].CorID < events[j].CorID + } + return events[i].cursorAt.Before(events[j].cursorAt) + }) + return events, nil +} + +func prepareSSIEventFetch(symbol string, after, through time.Time, emptyErr, rangeErr string) (normalized string, from, to time.Time, err error) { + normalized = strings.ToUpper(strings.TrimSpace(symbol)) + if normalized == "" { + return "", time.Time{}, time.Time{}, errors.New(emptyErr) + } + if after.IsZero() || through.IsZero() || !through.After(after) { + return "", time.Time{}, time.Time{}, errors.New(rangeErr) + } + return normalized, after.In(saigonLocation).AddDate(0, 0, -1), through.In(saigonLocation), nil +} + +func (p *SSIDividendProvider) fetchUniqueCorporateActions(ctx context.Context, symbol string, from, to time.Time, kind string) ([]ssiDividendEvent, error) { + seen := make(map[string]struct{}) + events := make([]ssiDividendEvent, 0) totalPages := 1 for page := 1; page <= totalPages; page++ { if page > ssiDividendMaxPages { - return nil, fmt.Errorf("stock: SSI dividend response exceeds %d pages", ssiDividendMaxPages) + return nil, fmt.Errorf("stock: SSI %s response exceeds %d pages", kind, ssiDividendMaxPages) } body, err := p.fetchPage(ctx, symbol, from, to, page) if err != nil { - return nil, fmt.Errorf("stock: fetch SSI dividend page %d: %w", page, err) + return nil, fmt.Errorf("stock: fetch SSI %s page %d: %w", kind, page, err) } if body.Paging.TotalPage > ssiDividendMaxPages { - return nil, fmt.Errorf("stock: SSI dividend response has %d pages, maximum is %d", body.Paging.TotalPage, ssiDividendMaxPages) + return nil, fmt.Errorf("stock: SSI %s response has %d pages, maximum is %d", kind, body.Paging.TotalPage, ssiDividendMaxPages) } if body.Paging.TotalPage > totalPages { totalPages = body.Paging.TotalPage @@ -148,22 +218,51 @@ func (p *SSIDividendProvider) FetchDividendEvents(ctx context.Context, symbol st continue } seen[id] = struct{}{} - event, ok := p.normalizeEvent(raw, symbol) - if !ok || event.PublishedAt.Before(lowerBound) || event.PublishedAt.After(through) { - continue - } - events = append(events, event) + events = append(events, raw) } } - sort.Slice(events, func(i, j int) bool { - if events[i].PublishedAt.Equal(events[j].PublishedAt) { - return events[i].ProviderID < events[j].ProviderID - } - return events[i].PublishedAt.Before(events[j].PublishedAt) - }) return events, nil } +func (p *SSIDividendProvider) copyStockEvent(raw ssiDividendEvent, requestedSymbol string) (SSIStockEvent, bool) { + symbol := strings.ToUpper(strings.TrimSpace(raw.Symbol)) + if symbol != requestedSymbol { + return SSIStockEvent{}, false + } + cursorAt, ok := ssiStockEventCursor(raw) + if !ok { + return SSIStockEvent{}, false + } + return SSIStockEvent{ + CorID: strings.TrimSpace(raw.CorID), + Symbol: symbol, + EventListCode: strings.TrimSpace(raw.EventListCode), + EventName: strings.TrimSpace(raw.EventName), + EventTitle: strings.TrimSpace(raw.EventTitle), + EventDescription: strings.TrimSpace(raw.EventDescription), + PublicDate: strings.TrimSpace(raw.PublicDate), + ExrightDate: strings.TrimSpace(raw.ExrightDate), + RecordDate: strings.TrimSpace(raw.RecordDate), + IssueDate: strings.TrimSpace(raw.IssueDate), + Value: strings.TrimSpace(string(raw.Value)), + Ratio: strings.TrimSpace(string(raw.Ratio)), + SourceURL: p.eventSourceURL(symbol, raw.CorID), + cursorAt: cursorAt, + }, true +} + +func ssiStockEventCursor(raw ssiDividendEvent) (time.Time, bool) { + if strings.TrimSpace(raw.PublicDate) != "" { + return parseSSIDate(raw.PublicDate) + } + for _, candidate := range []string{raw.ExrightDate, raw.RecordDate, raw.IssueDate} { + if parsed, ok := parseSSIDate(candidate); ok { + return parsed, true + } + } + return time.Time{}, false +} + func startOfSaigonDay(value time.Time) time.Time { local := value.In(saigonLocation) return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, saigonLocation) diff --git a/internal/modules/stock/dividend_events_ssi_test.go b/internal/modules/stock/dividend_events_ssi_test.go index be5fc04..ec72502 100644 --- a/internal/modules/stock/dividend_events_ssi_test.go +++ b/internal/modules/stock/dividend_events_ssi_test.go @@ -79,6 +79,90 @@ func TestSSIDividendProviderPaginatesDeduplicatesFiltersAndNormalizes(t *testing } } +func TestSSIStockEventProviderIncludesAllTypesPaginatesDeduplicatesFiltersAndOrders(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + w.Header().Set("Content-Type", "application/json") + switch page { + case 1: + _, _ = fmt.Fprint(w, `{"code":"SUCCESS","status":"ok","paging":{"totalPage":2,"page":1},"data":[ + {"CorId":"meeting","symbol":"TCB","eventListCode":"AGM","eventName":"Annual meeting","eventTitle":"Meeting title","eventDescription":"Meeting description","publicDate":"11/06/2026 12:00:00","recordDate":"malformed-raw-date","value":"123.45","ratio":"0.25"}, + {"CorId":"tie-b","symbol":"TCB","eventListCode":"OTHER","eventTitle":"Second by ID","publicDate":"12/06/2026 00:00:00"}, + {"CorId":"at-after","symbol":"TCB","eventListCode":"OTHER","publicDate":"10/06/2026 12:00:00"}, + {"CorId":"overlap-only","symbol":"TCB","eventListCode":"OTHER","publicDate":"10/06/2026 00:00:00"}, + {"CorId":"too-old","symbol":"TCB","eventListCode":"OTHER","publicDate":"08/06/2026 23:59:59"}, + {"CorId":"wrong-symbol","symbol":"ACB","eventListCode":"AGM","publicDate":"11/06/2026"} + ]}`) + case 2: + _, _ = fmt.Fprint(w, `{"code":"SUCCESS","status":"ok","paging":{"totalPage":2,"page":2},"data":[ + {"CorId":"meeting","symbol":"TCB","eventListCode":"AGM","eventTitle":"duplicate","publicDate":"11/06/2026 12:00:00"}, + {"CorId":"tie-a","symbol":"TCB","eventListCode":"ISS","eventName":"Rights issue","eventTitle":"First by ID","publicDate":"12/06/2026 00:00:00","exrightDate":"20/06/2026","issueDate":"30/06/2026"}, + {"CorId":"after-through","symbol":"TCB","eventListCode":"OTHER","publicDate":"12/06/2026 00:00:01"}, + {"CorId":"bad-date","symbol":"TCB","eventListCode":"OTHER","publicDate":"not-a-date","exrightDate":"11/06/2026"}, + {"CorId":"fallback-date","symbol":"TCB","eventListCode":"FALLBACK","eventDescription":"raw fallback event","exrightDate":"bad-optional","recordDate":"11/06/2026","issueDate":"also-bad"} + ]}`) + default: + t.Fatalf("unexpected page %d", page) + } + })) + defer server.Close() + + provider := &SSIDividendProvider{HTTP: server.Client(), BaseURL: server.URL} + events, err := provider.FetchStockEvents(context.Background(), " tcb ", saigonTime(t, "10/06/2026 12:00:00"), saigonTime(t, "12/06/2026 00:00:00")) + if err != nil { + t.Fatalf("FetchStockEvents: %v", err) + } + if len(events) != 4 { + t.Fatalf("events = %+v, want fallback, meeting, and two tie events", events) + } + if events[0].CorID != "fallback-date" || events[0].PublicDate != "" || events[0].ExrightDate != "bad-optional" || events[0].RecordDate != "11/06/2026" { + t.Fatalf("fallback cursor/raw fields = %+v", events[0]) + } + if events[1].CorID != "meeting" || events[1].EventListCode != "AGM" || events[1].EventName != "Annual meeting" || events[1].EventTitle != "Meeting title" || events[1].EventDescription != "Meeting description" || events[1].RecordDate != "malformed-raw-date" || events[1].Value != "123.45" || events[1].Ratio != "0.25" { + t.Fatalf("generic non-dividend raw event = %+v", events[1]) + } + if events[2].CorID != "tie-a" || events[3].CorID != "tie-b" { + t.Fatalf("tie order = %q, %q", events[2].CorID, events[3].CorID) + } + if events[2].ExrightDate != "20/06/2026" || events[2].IssueDate != "30/06/2026" || events[2].SourceURL == "" { + t.Fatalf("raw event dates/source missing: %+v", events[2]) + } + for _, event := range events { + if event.CorID == "bad-date" { + t.Fatalf("malformed publicDate was accepted via optional fallback: %+v", event) + } + } + + dividends, err := provider.FetchDividendEvents(context.Background(), "TCB", saigonTime(t, "10/06/2026 12:00:00"), saigonTime(t, "12/06/2026 00:00:00")) + if err != nil { + t.Fatalf("FetchDividendEvents: %v", err) + } + if len(dividends) != 0 { + t.Fatalf("generic events leaked into dividend path: %+v", dividends) + } +} + +func TestSSIStockEventProviderRequiresEveryPageAndValidRange(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "2" { + http.Error(w, "upstream unavailable", http.StatusServiceUnavailable) + return + } + _, _ = fmt.Fprint(w, `{"code":"SUCCESS","status":"ok","paging":{"totalPage":2,"page":1},"data":[]}`) + })) + defer server.Close() + provider := &SSIDividendProvider{HTTP: server.Client(), BaseURL: server.URL} + now := saigonTime(t, "12/06/2026") + if events, err := provider.FetchStockEvents(context.Background(), "TCB", now.Add(-24*time.Hour), now); err == nil || events != nil { + t.Fatalf("partial events, err = %+v, %v", events, err) + } + if _, err := provider.FetchStockEvents(context.Background(), "TCB", now, now); err == nil { + t.Fatal("equal range accepted") + } +} + func TestSSIDividendProviderUsesDayOverlapAndDateFallback(t *testing.T) { t.Parallel() server := dividendTestServer(t, http.StatusOK, `{"code":"SUCCESS","status":"ok","paging":{"totalPage":1,"page":1},"data":[ diff --git a/internal/modules/stock/handlers.go b/internal/modules/stock/handlers.go index 26d0cc5..c88bdfb 100644 --- a/internal/modules/stock/handlers.go +++ b/internal/modules/stock/handlers.go @@ -25,6 +25,7 @@ type state struct { pending PendingDividendStore prices *PriceClient dividends DividendEventProvider + events SSIStockEventProvider locks keylock.Map nowFn func() time.Time newDividendToken func() (string, error) @@ -39,11 +40,13 @@ func (s *state) now() time.Time { // newState builds the default state used by the module factory. func newState(store Store, pending PendingDividendStore) *state { + ssi := &SSIDividendProvider{} return &state{ store: store, pending: pending, prices: &PriceClient{}, - dividends: &SSIDividendProvider{}, + dividends: ssi, + events: ssi, } } diff --git a/internal/modules/stock/handlers_test.go b/internal/modules/stock/handlers_test.go index 482445c..2b576dd 100644 --- a/internal/modules/stock/handlers_test.go +++ b/internal/modules/stock/handlers_test.go @@ -24,6 +24,7 @@ func TestModuleRegistersExpectedCommands(t *testing.T) { } for _, name := range []string{ "stock_price", + "stock_events", "stock_topup", "stock_buy", "stock_sell", diff --git a/internal/modules/stock/stock.go b/internal/modules/stock/stock.go index d1852a8..df6c231 100644 --- a/internal/modules/stock/stock.go +++ b/internal/modules/stock/stock.go @@ -5,7 +5,7 @@ import ( "github.com/tiennm99/miti99bot/internal/storage" ) -// New is the stock module Factory. Seven user-facing commands. +// New is the stock module Factory. Eight user-facing commands. func New(deps modules.Deps) modules.Module { s := newState( storage.Typed[Portfolio](deps.Store), @@ -14,6 +14,13 @@ func New(deps modules.Deps) modules.Module { return modules.Module{ Callbacks: []modules.Callback{{Prefix: dividendCallbackPrefix, Visibility: modules.VisibilityPublic, Handler: s.handleDividendCallback}}, Commands: []modules.Command{ + { + Name: "stock_events", + Visibility: modules.VisibilityPublic, + Description: "Show SSI corporate actions for a VN stock", + Parameters: " [days]", + Handler: s.handleStockEvents, + }, { Name: "stock_price", Visibility: modules.VisibilityPublic, diff --git a/internal/modules/stock/stock_events.go b/internal/modules/stock/stock_events.go new file mode 100644 index 0000000..c2ea32a --- /dev/null +++ b/internal/modules/stock/stock_events.go @@ -0,0 +1,176 @@ +package stock + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + "unicode/utf8" + + "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 ( + stockEventsDefaultDays = 30 + stockEventsMaxDays = 90 + stockEventsReplyLimit = 4000 + stockEventTitleLimit = 500 +) + +// SSIStockEventProvider returns SSI corporate-action fields for display. It is +// separate from DividendEventProvider and does not imply portfolio semantics. +type SSIStockEventProvider interface { + FetchStockEvents(ctx context.Context, symbol string, after, through time.Time) ([]SSIStockEvent, error) +} + +// SSIStockEvent preserves SSI's raw corporate-action fields. cursorAt is used +// only for range filtering and deterministic ordering; it is not displayed in +// place of the source strings. +type SSIStockEvent struct { + CorID string + Symbol string + EventListCode string + EventName string + EventTitle string + EventDescription string + PublicDate string + ExrightDate string + RecordDate string + IssueDate string + Value string + Ratio string + SourceURL string + + cursorAt time.Time +} + +func (s *state) handleStockEvents(ctx context.Context, b *bot.Bot, update *models.Update) error { + args := argsAfterCommand(update.Message.Text) + if len(args) < 1 || len(args) > 2 { + return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_events [days]") + } + + days := stockEventsDefaultDays + if len(args) == 2 { + parsed, err := strconv.Atoi(args[1]) + if err != nil || parsed < 1 || parsed > stockEventsMaxDays { + return chathelper.Reply(ctx, b, update.Message, "Days must be a whole number from 1 to 90.") + } + days = parsed + } + + symbol, err := normalizeStockSymbol(args[0]) + if err != nil { + if errors.Is(err, ErrUnknownTicker) { + return chathelper.Reply(ctx, b, update.Message, "Unknown stock ticker \""+strings.ToUpper(args[0])+"\".") + } + return chathelper.Reply(ctx, b, update.Message, "Could not parse that ticker. Try again later.") + } + + through := s.now() + after := through.Add(-time.Duration(days) * 24 * time.Hour) + fetchCtx, cancel := chathelper.FetchContext(ctx) + defer cancel() + events, err := s.events.FetchStockEvents(fetchCtx, symbol, after, through) + if err != nil { + log.Error("stock_fetch_events", "ticker", symbol, "days", days, "err", err) + return chathelper.Reply(ctx, b, update.Message, "Could not fetch stock events for "+symbol+". Try again later.") + } + if len(events) == 0 { + return chathelper.Reply(ctx, b, update.Message, fmt.Sprintf("No stock events found for %s in the last %d days.", symbol, days)) + } + + blocks := make([]string, 0, len(events)) + for _, event := range events { + blocks = append(blocks, formatStockEvent(event)) + } + for _, reply := range chunkStockEventReplies(symbol, blocks) { + if err := chathelper.Reply(ctx, b, update.Message, reply); err != nil { + return err + } + } + return nil +} + +func formatStockEvent(event SSIStockEvent) string { + code := truncateRunes(strings.TrimSpace(event.EventListCode), 80) + if code == "" { + code = "Corporate action" + } + lines := []string{event.Symbol + " SSI event · " + code} + if name := truncateRunes(strings.TrimSpace(event.EventName), 240); name != "" { + lines = append(lines, "Name: "+name) + } + if title := truncateRunes(strings.TrimSpace(event.EventTitle), stockEventTitleLimit); title != "" { + lines = append(lines, "Title: "+title) + } + if description := truncateRunes(strings.TrimSpace(event.EventDescription), 700); description != "" { + lines = append(lines, "Description: "+description) + } + if value := truncateRunes(strings.TrimSpace(event.Value), 160); value != "" { + lines = append(lines, "Value: "+value) + } + if ratio := truncateRunes(strings.TrimSpace(event.Ratio), 160); ratio != "" { + lines = append(lines, "Ratio: "+ratio) + } + for _, field := range []struct { + label string + value string + }{ + {"Published", event.PublicDate}, + {"Ex-right", event.ExrightDate}, + {"Record", event.RecordDate}, + {"Issue/payment", event.IssueDate}, + } { + if value := truncateRunes(strings.TrimSpace(field.value), 100); value != "" { + lines = append(lines, field.label+": "+value) + } + } + lines = append(lines, "SSI event: "+event.CorID) + if source := strings.TrimSpace(event.SourceURL); source != "" { + lines = append(lines, "Source: "+source) + } + return strings.Join(lines, "\n") +} + +func chunkStockEventReplies(symbol string, blocks []string) []string { + // Reserve enough space for the heading and part numbering so every final + // reply remains below Telegram's 4000-character safety margin. + const bodyLimit = stockEventsReplyLimit - 80 + chunks := make([]string, 0, 1) + current := "" + for _, block := range blocks { + if utf8.RuneCountInString(block) > bodyLimit { + block = truncateRunes(block, bodyLimit-16) + "\n…(truncated)" + } + candidate := block + if current != "" { + candidate = current + "\n\n" + block + } + if utf8.RuneCountInString(candidate) > bodyLimit && current != "" { + chunks = append(chunks, current) + current = block + } else { + current = candidate + } + } + if current != "" { + chunks = append(chunks, current) + } + + replies := make([]string, len(chunks)) + for i, chunk := range chunks { + heading := symbol + " events" + if len(chunks) > 1 { + heading += fmt.Sprintf(" (%d/%d)", i+1, len(chunks)) + } + replies[i] = heading + "\n\n" + chunk + } + return replies +} diff --git a/internal/modules/stock/stock_events_test.go b/internal/modules/stock/stock_events_test.go new file mode 100644 index 0000000..32167b8 --- /dev/null +++ b/internal/modules/stock/stock_events_test.go @@ -0,0 +1,145 @@ +package stock + +import ( + "context" + "errors" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/tiennm99/miti99bot/internal/testutil" +) + +type stockEventProviderFunc func(context.Context, string, time.Time, time.Time) ([]SSIStockEvent, error) + +func (f stockEventProviderFunc) FetchStockEvents(ctx context.Context, symbol string, after, through time.Time) ([]SSIStockEvent, error) { + return f(ctx, symbol, after, through) +} + +func TestStockEventsCommandRegistration(t *testing.T) { + mod := New(modDepsForTest()) + for _, command := range mod.Commands { + if command.Name != "stock_events" { + continue + } + if command.Parameters != " [days]" || command.Description != "Show SSI corporate actions for a VN stock" { + t.Fatalf("stock_events metadata = params %q description %q", command.Parameters, command.Description) + } + return + } + t.Fatal("stock_events command is not registered") +} + +func TestHandleStockEventsDefaultWindowAndSenderless(t *testing.T) { + now := time.Date(2026, 7, 23, 15, 30, 0, 0, saigonLocation) + called := false + s := &state{ + nowFn: func() time.Time { return now }, + events: stockEventProviderFunc(func(_ context.Context, symbol string, after, through time.Time) ([]SSIStockEvent, error) { + called = true + if symbol != "TCB" || !through.Equal(now) || !after.Equal(now.Add(-30*24*time.Hour)) { + t.Errorf("provider args = %q, %v, %v", symbol, after, through) + } + return []SSIStockEvent{{ + CorID: "meeting-1", Symbol: "TCB", EventListCode: "AGM", EventName: "Annual meeting", + EventTitle: "Shareholder meeting", PublicDate: "23/07/2026 14:30:00", SourceURL: "https://ssi.example/event", + }}, nil + }), + } + rb := testutil.NewRecordingBot(t) + if err := s.handleStockEvents(context.Background(), rb.Bot, testutil.NewChannelMessage(-100, "/stock_events tcb")); err != nil { + t.Fatalf("handleStockEvents: %v", err) + } + if !called { + t.Fatal("provider was not called") + } + rb.AssertSentText(t, "TCB SSI event · AGM") + rb.AssertSentText(t, "Name: Annual meeting") + rb.AssertSentText(t, "SSI event: meeting-1") +} + +func TestHandleStockEventsExplicitWindowAndValidation(t *testing.T) { + now := time.Date(2026, 7, 23, 15, 30, 0, 0, saigonLocation) + s := &state{ + nowFn: func() time.Time { return now }, + events: stockEventProviderFunc(func(_ context.Context, _ string, after, through time.Time) ([]SSIStockEvent, error) { + if !after.Equal(now.Add(-7*24*time.Hour)) || !through.Equal(now) { + t.Fatalf("window = %v..%v", after, through) + } + return nil, nil + }), + } + rb := testutil.NewRecordingBot(t) + if err := s.handleStockEvents(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_events TCB 7")); err != nil { + t.Fatal(err) + } + rb.AssertSentText(t, "No stock events found for TCB in the last 7 days.") + + for _, tc := range []struct { + command string + want string + }{ + {"/stock_events", "Usage: /stock_events [days]"}, + {"/stock_events TCB 7 extra", "Usage: /stock_events [days]"}, + {"/stock_events TCB 0", "Days must be a whole number from 1 to 90."}, + {"/stock_events TCB 91", "Days must be a whole number from 1 to 90."}, + {"/stock_events TCB 1.5", "Days must be a whole number from 1 to 90."}, + {"/stock_events $$$", "Unknown stock ticker \"$$$\"."}, + } { + rb.Reset() + if err := s.handleStockEvents(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, tc.command)); err != nil { + t.Fatalf("%q: %v", tc.command, err) + } + rb.AssertSentText(t, tc.want) + } +} + +func TestHandleStockEventsProviderError(t *testing.T) { + s := &state{ + events: stockEventProviderFunc(func(context.Context, string, time.Time, time.Time) ([]SSIStockEvent, error) { + return nil, errors.New("upstream unavailable") + }), + } + rb := testutil.NewRecordingBot(t) + if err := s.handleStockEvents(context.Background(), rb.Bot, testutil.NewPrivateMessage(7, "/stock_events TCB")); err != nil { + t.Fatal(err) + } + rb.AssertSentText(t, "Could not fetch stock events for TCB. Try again later.") +} + +func TestStockEventFormattingAndChunking(t *testing.T) { + unicodeTitle := strings.Repeat("ổ", stockEventTitleLimit+50) + block := formatStockEvent(SSIStockEvent{ + CorID: "event-1", Symbol: "TCB", EventListCode: "ISS", EventName: "Issue", + EventTitle: unicodeTitle, EventDescription: "Raw API description", Value: "1500.25", Ratio: "0.125", + PublicDate: "20/07/2026 10:00:00", ExrightDate: "malformed-but-displayed", RecordDate: "22/07/2026", + IssueDate: "30/07/2026", SourceURL: "https://ssi.example/event-1", + }) + if !strings.Contains(block, "Title: "+strings.Repeat("ổ", stockEventTitleLimit-1)+"…") { + t.Fatal("title was not truncated rune-safely") + } + for _, expected := range []string{ + "Description: Raw API description", "Value: 1500.25", "Ratio: 0.125", + "Published: 20/07/2026 10:00:00", "Ex-right: malformed-but-displayed", "Record: 22/07/2026", + "Issue/payment: 30/07/2026", "Source: https://ssi.example/event-1", + } { + if !strings.Contains(block, expected) { + t.Errorf("block missing %q: %q", expected, block) + } + } + + blocks := []string{strings.Repeat("a", 2500), strings.Repeat("b", 2500), "final-event"} + replies := chunkStockEventReplies("TCB", blocks) + if len(replies) != 2 { + t.Fatalf("reply count = %d, want 2", len(replies)) + } + for i, reply := range replies { + if utf8.RuneCountInString(reply) >= stockEventsReplyLimit { + t.Errorf("reply %d length = %d", i, utf8.RuneCountInString(reply)) + } + } + if !strings.Contains(replies[0], "(1/2)") || !strings.Contains(replies[1], "(2/2)") || !strings.HasSuffix(replies[1], "final-event") { + t.Fatalf("chunk ordering/headings = %#v", replies) + } +}