From d9a136cb40c7d1827c0d301296e68fbdfb6bc152 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 9 May 2026 17:14:01 +0700 Subject: [PATCH] feat(modules): port lolschedule (5 commands; daily-push cron deferred) Phase 6e (final sub-phase of port-plan Phase 06). LoL esports match schedule via lolesports.com's persisted API. - internal/modules/lolschedule: - api_client.go: HTTP client with cache-first lookup (120s fresh window, 60-min stale fallback). Cache record shape matches JS so cross-runtime KV migration round-trips. - parse_date.go: ICT-anchored date parser. Accepts dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy; trailing month/year may be omitted (default to current ICT month/year). Rejects impossible dates (Apr 31, Feb 29 in non-leap, etc.). - format.go: Today (grouped by league) and Week (grouped by league -> day) renderers. Major-league filter (LCK/LPL/LEC/LCS/Worlds/ MSI/etc.) keeps replies under Telegram's 4096-char limit. All user-influenced strings HTML-escaped. - subscribers.go: Idempotent add/remove/list keyed by chat id. - handlers.go: 5 commands (`/lolschedule [date]`, `/lolschedule_today`, `/lolschedule_week`, `/lolschedule_subscribe`, `/lolschedule_unsubscribe`). - 22 tests across api-client (cache hit / miss / stale fallback / hard fail / show filter / non-JSON), parse-date (full and short formats, defaults, rejections, ICT anchor), format (event line states, league ordering, week grouping, HTML escape, major filter), subscribers (idempotent add/remove), handlers (HTML reply, error path, subscribe/unsubscribe round-trip). Daily-push cron deferred to Phase 09 (Cloud Scheduler). Subscribers are still collected so the push lights up the moment the cron infra lands. Deps doesn't currently expose a *bot.Bot reference; that is the prerequisite that Phase 09 will solve. go test -race -count=1 ./... clean (19 packages); golangci-lint clean. --- cmd/server/main.go | 2 + internal/modules/lolschedule/api_client.go | 274 ++++++++++++++++++ .../modules/lolschedule/api_client_test.go | 187 ++++++++++++ internal/modules/lolschedule/format.go | 262 +++++++++++++++++ internal/modules/lolschedule/format_test.go | 194 +++++++++++++ internal/modules/lolschedule/handlers.go | 120 ++++++++ internal/modules/lolschedule/handlers_test.go | 172 +++++++++++ internal/modules/lolschedule/lolschedule.go | 50 ++++ internal/modules/lolschedule/parse_date.go | 127 ++++++++ .../modules/lolschedule/parse_date_test.go | 116 ++++++++ internal/modules/lolschedule/subscribers.go | 73 +++++ .../modules/lolschedule/subscribers_test.go | 59 ++++ .../phase-06-port-loldle-variants.md | 6 +- plans/260508-2222-go-port-cloud-run/plan.md | 2 +- 14 files changed, 1639 insertions(+), 5 deletions(-) create mode 100644 internal/modules/lolschedule/api_client.go create mode 100644 internal/modules/lolschedule/api_client_test.go create mode 100644 internal/modules/lolschedule/format.go create mode 100644 internal/modules/lolschedule/format_test.go create mode 100644 internal/modules/lolschedule/handlers.go create mode 100644 internal/modules/lolschedule/handlers_test.go create mode 100644 internal/modules/lolschedule/lolschedule.go create mode 100644 internal/modules/lolschedule/parse_date.go create mode 100644 internal/modules/lolschedule/parse_date_test.go create mode 100644 internal/modules/lolschedule/subscribers.go create mode 100644 internal/modules/lolschedule/subscribers_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index d4e697c..c35b2aa 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -18,6 +18,7 @@ import ( "github.com/tiennm99/miti99bot-go/internal/modules/loldleemoji" "github.com/tiennm99/miti99bot-go/internal/modules/loldlequote" "github.com/tiennm99/miti99bot-go/internal/modules/loldlesplash" + "github.com/tiennm99/miti99bot-go/internal/modules/lolschedule" "github.com/tiennm99/miti99bot-go/internal/modules/misc" "github.com/tiennm99/miti99bot-go/internal/modules/util" "github.com/tiennm99/miti99bot-go/internal/modules/wordle" @@ -39,6 +40,7 @@ func factories() map[string]modules.Factory { "loldle-emoji": loldleemoji.New, "loldle-quote": loldlequote.New, "loldle-splash": loldlesplash.New, + "lolschedule": lolschedule.New, } } diff --git a/internal/modules/lolschedule/api_client.go b/internal/modules/lolschedule/api_client.go new file mode 100644 index 0000000..39bc939 --- /dev/null +++ b/internal/modules/lolschedule/api_client.go @@ -0,0 +1,274 @@ +// Package lolschedule ports the JS lolschedule module — LoL esports match +// schedule via lolesports.com's persisted API. +// +// Endpoint: https://esports-api.lolesports.com/persisted/gw/getSchedule +// Auth: x-api-key header (the public key embedded in lolesports.com's web +// client — no registration). If Riot ever rotates it, lift the new value +// from their public JS bundle. +// +// Cache strategy: KV-backed, 120s fresh window with 60-minute stale +// fallback. Same shape as the JS source so cross-runtime KV migration +// round-trips byte-for-byte. +package lolschedule + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/tiennm99/miti99bot-go/internal/log" + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +const ( + apiURL = "https://esports-api.lolesports.com/persisted/gw/getSchedule" + // apiKey is the public lolesports.com web client key (not a secret). + // gosec flags it as a hardcoded credential; the value is shipped in + // Riot's own public JS bundle and serves the live site too. + // #nosec G101 + apiKey = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z" + userAgent = "miti99bot-go/0.1 (https://t.me/miti99bot)" + // CacheTTL: schedule data changes minute-by-minute during live events. + cacheTTL = 120 * time.Second + // staleMaxAge: how long to fall back to a cached payload when the + // upstream call fails outright. + staleMaxAge = 60 * 60 * time.Second + // httpTimeout: keep upstream calls bounded so a hung lolesports edge + // can't hold a Cloud Run instance. + httpTimeout = 8 * time.Second +) + +// Team is one side of a match. JSON shape matches the lolesports response. +type Team struct { + Name string `json:"name,omitempty"` + Code string `json:"code,omitempty"` + Image string `json:"image,omitempty"` + Result *struct { + Outcome string `json:"outcome,omitempty"` // "win" or "loss" + GameWins int `json:"gameWins,omitempty"` + } `json:"result,omitempty"` + Record *struct { + Wins int `json:"wins,omitempty"` + Losses int `json:"losses,omitempty"` + } `json:"record,omitempty"` +} + +// League holds the league-section-header info on each event. +type League struct { + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` + Image string `json:"image,omitempty"` +} + +// Strategy is the bestOf descriptor (Bo1, Bo3, Bo5). +type Strategy struct { + Type string `json:"type,omitempty"` + Count int `json:"count,omitempty"` +} + +// Match is the inner match metadata. +type Match struct { + ID string `json:"id,omitempty"` + Teams []Team `json:"teams,omitempty"` + Strategy Strategy `json:"strategy,omitempty"` +} + +// ScheduleEvent is one upcoming or past match. State is "unstarted", +// "inProgress", or "completed". Type is set to "show" for pre/post-show +// segments which we filter out. +type ScheduleEvent struct { + StartTime string `json:"startTime"` + State string `json:"state,omitempty"` + Type string `json:"type,omitempty"` + BlockName string `json:"blockName,omitempty"` + League League `json:"league,omitempty"` + Match Match `json:"match,omitempty"` +} + +// schedulePage is the inner shape of an upstream response. +type schedulePage struct { + Data struct { + Schedule struct { + Events []ScheduleEvent `json:"events"` + Pages struct { + Newer string `json:"newer,omitempty"` + Older string `json:"older,omitempty"` + } `json:"pages,omitempty"` + } `json:"schedule"` + } `json:"data"` +} + +// cacheRecord is the KV value: timestamp + events. Same shape as JS so KV +// export/import migration round-trips. +type cacheRecord struct { + Ts int64 `json:"ts"` // ms-since-epoch when fetched + Events []ScheduleEvent `json:"events"` +} + +// Client is the lolesports API client. Default zero-value uses +// http.DefaultClient + http.DefaultTransport; tests inject a custom HTTP +// client (typically pointing at httptest.Server). +type Client struct { + HTTP *http.Client + URL string // override for tests; empty falls back to apiURL +} + +// httpClient returns the client to use, or a sensible default. +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: httpTimeout} +} + +func (c *Client) baseURL() string { + if c.URL != "" { + return c.URL + } + return apiURL +} + +// fetchSchedulePage retrieves one page of events. pageToken is the forward +// cursor from a previous call's `pages.newer`. +func (c *Client) fetchSchedulePage(ctx context.Context, pageToken string) ([]ScheduleEvent, string, error) { + u, err := url.Parse(c.baseURL()) + if err != nil { + return nil, "", fmt.Errorf("lolschedule parse url: %w", err) + } + q := u.Query() + q.Set("hl", "en-US") + if pageToken != "" { + q.Set("pageToken", pageToken) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, "", fmt.Errorf("lolschedule build request: %w", err) + } + req.Header.Set("x-api-key", apiKey) + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, "", fmt.Errorf("lolschedule do: %w", err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("lolschedule read: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Warn("lolschedule_fetch", "status", resp.StatusCode, "body", truncate(string(body), 500)) + return nil, "", fmt.Errorf("lolschedule API HTTP %d", resp.StatusCode) + } + var page schedulePage + if err := json.Unmarshal(body, &page); err != nil { + return nil, "", fmt.Errorf("lolschedule decode: %w", err) + } + // Drop pre/post-show segments; they aren't matches. + out := make([]ScheduleEvent, 0, len(page.Data.Schedule.Events)) + for _, e := range page.Data.Schedule.Events { + if e.Type == "show" { + continue + } + out = append(out, e) + } + return out, page.Data.Schedule.Pages.Newer, nil +} + +// fetchEventsInRange paginates forward until the supplied window is covered +// or maxPages is reached. Default page returns ~20 events; week view +// usually needs 1 extra page. +func (c *Client) fetchEventsInRange(ctx context.Context, from, to time.Time, maxPages int) ([]ScheduleEvent, error) { + if maxPages <= 0 { + maxPages = 3 + } + var collected []ScheduleEvent + pageToken := "" + for i := 0; i < maxPages; i++ { + events, newer, err := c.fetchSchedulePage(ctx, pageToken) + if err != nil { + return nil, err + } + collected = append(collected, events...) + // If the latest event in the page is already past our window end, stop. + if len(events) > 0 { + lastT, parseErr := time.Parse(time.RFC3339, events[len(events)-1].StartTime) + if parseErr == nil && !lastT.Before(to) { + break + } + } + if newer == "" { + break + } + pageToken = newer + } + out := make([]ScheduleEvent, 0, len(collected)) + for _, e := range collected { + t, err := time.Parse(time.RFC3339, e.StartTime) + if err != nil { + continue + } + if !t.Before(from) && t.Before(to) { + out = append(out, e) + } + } + return out, nil +} + +// cacheKey is `matches::` — a stable key for a date range. +func cacheKey(from, to time.Time) string { + return "matches:" + from.UTC().Format(time.RFC3339) + ":" + to.UTC().Format(time.RFC3339) +} + +// GetEventsCached is the cache-first lookup. Returns fresh cache within +// cacheTTL, else fetches upstream and writes back, else falls back to +// stale cache (within staleMaxAge), else propagates the error. +func (c *Client) GetEventsCached(ctx context.Context, kv storage.KVStore, from, to time.Time) ([]ScheduleEvent, error) { + key := cacheKey(from, to) + now := time.Now().UTC().UnixMilli() + + var cached cacheRecord + cacheErr := kv.GetJSON(ctx, key, &cached) + hasCached := cacheErr == nil + if hasCached && now-cached.Ts < cacheTTL.Milliseconds() { + return cached.Events, nil + } + + events, fetchErr := c.fetchEventsInRange(ctx, from, to, 3) + if fetchErr == nil { + rec := cacheRecord{Ts: now, Events: events} + if err := kv.PutJSON(ctx, key, rec); err != nil { + log.Warn("lolschedule_kv_put_fail", "err", err) + } + return events, nil + } + + // Upstream failed — fall back to stale cache if recent enough. + if hasCached && cached.Events != nil && now-cached.Ts < staleMaxAge.Milliseconds() { + log.Warn("lolschedule_stale_fallback", "err", fetchErr) + return cached.Events, nil + } + return nil, fetchErr +} + +// truncate clips a string to maxLen runes with "..." if cut. Keeps the log +// output bounded — lolesports occasionally returns multi-MB error pages. +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +// ErrEmptyResult is reserved for explicit "no events" scenarios where the +// fetch succeeded but returned zero matches. Currently unused outside tests +// but kept exported so callers can distinguish from network errors. +var ErrEmptyResult = errors.New("lolschedule: no events in range") diff --git a/internal/modules/lolschedule/api_client_test.go b/internal/modules/lolschedule/api_client_test.go new file mode 100644 index 0000000..d00c9c7 --- /dev/null +++ b/internal/modules/lolschedule/api_client_test.go @@ -0,0 +1,187 @@ +package lolschedule + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +// mkServer spins an httptest.Server returning the supplied JSON body for +// every page request. callCount counts upstream hits so cache tests can +// assert "1 fetch, then no more". +func mkServer(t *testing.T, body string) (*httptest.Server, *int32) { + t.Helper() + var count int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&count, 1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv, &count +} + +const sampleBody = `{ + "data": { + "schedule": { + "events": [ + { + "startTime": "2026-05-09T05:00:00Z", + "state": "unstarted", + "league": {"slug": "lck", "name": "LCK"}, + "match": {"teams": [{"code":"T1"},{"code":"GEN"}], "strategy":{"count":3}} + }, + { + "startTime": "2026-05-09T08:00:00Z", + "state": "unstarted", + "type": "show", + "league": {"slug": "lck", "name": "LCK"}, + "match": {"teams": [], "strategy":{}} + } + ], + "pages": {"newer": null} + } + } +}` + +func TestGetEventsCached_FirstHitFetchesUpstream(t *testing.T) { + srv, count := mkServer(t, sampleBody) + c := &Client{HTTP: srv.Client(), URL: srv.URL} + kv := storage.NewMemoryKVStore() + from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) + to := from.Add(24 * time.Hour) + + events, err := c.GetEventsCached(context.Background(), kv, from, to) + if err != nil { + t.Fatalf("first fetch: %v", err) + } + if len(events) != 1 { + t.Errorf("events = %d, want 1 (show filtered out)", len(events)) + } + if events[0].League.Slug != "lck" { + t.Errorf("event slug = %q, want lck", events[0].League.Slug) + } + if atomic.LoadInt32(count) != 1 { + t.Errorf("upstream calls = %d, want 1", *count) + } +} + +func TestGetEventsCached_SecondHitUsesCache(t *testing.T) { + srv, count := mkServer(t, sampleBody) + c := &Client{HTTP: srv.Client(), URL: srv.URL} + kv := storage.NewMemoryKVStore() + from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) + to := from.Add(24 * time.Hour) + + // First fetch primes the cache. + if _, err := c.GetEventsCached(context.Background(), kv, from, to); err != nil { + t.Fatal(err) + } + // Second fetch within TTL must NOT hit upstream. + if _, err := c.GetEventsCached(context.Background(), kv, from, to); err != nil { + t.Fatal(err) + } + if got := atomic.LoadInt32(count); got != 1 { + t.Errorf("upstream calls = %d, want 1 (cache should serve second call)", got) + } +} + +func TestGetEventsCached_StaleFallback(t *testing.T) { + // Prime KV with a stale-but-still-fresh-enough cache record. + kv := storage.NewMemoryKVStore() + from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) + to := from.Add(24 * time.Hour) + staleEvents := []ScheduleEvent{ + {StartTime: "2026-05-09T05:00:00Z", League: League{Slug: "lck", Name: "LCK"}}, + } + // 10 minutes ago — past the 120s fresh window but well inside 60-min stale. + staleTs := time.Now().UTC().Add(-10 * time.Minute).UnixMilli() + if err := kv.PutJSON(context.Background(), cacheKey(from, to), cacheRecord{Ts: staleTs, Events: staleEvents}); err != nil { + t.Fatal(err) + } + + // Upstream errors — server returns 500. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"down"}`)) + })) + defer srv.Close() + c := &Client{HTTP: srv.Client(), URL: srv.URL} + + got, err := c.GetEventsCached(context.Background(), kv, from, to) + if err != nil { + t.Fatalf("stale fallback should succeed: %v", err) + } + if len(got) != 1 || got[0].League.Slug != "lck" { + t.Errorf("stale fallback returned wrong events: %+v", got) + } +} + +func TestGetEventsCached_HardFailureWhenNoCache(t *testing.T) { + kv := storage.NewMemoryKVStore() + from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) + to := from.Add(24 * time.Hour) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + c := &Client{HTTP: srv.Client(), URL: srv.URL} + + _, err := c.GetEventsCached(context.Background(), kv, from, to) + if err == nil { + t.Errorf("expected error when upstream fails AND no cache") + } +} + +func TestFetchSchedulePage_DropsShowEvents(t *testing.T) { + srv, _ := mkServer(t, sampleBody) + c := &Client{HTTP: srv.Client(), URL: srv.URL} + + events, _, err := c.fetchSchedulePage(context.Background(), "") + if err != nil { + t.Fatal(err) + } + for _, e := range events { + if e.Type == "show" { + t.Errorf("show event leaked: %+v", e) + } + } +} + +func TestFetchSchedulePage_NonJSONErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("not json")) + })) + defer srv.Close() + c := &Client{HTTP: srv.Client(), URL: srv.URL} + _, _, err := c.fetchSchedulePage(context.Background(), "") + if err == nil || !strings.Contains(err.Error(), "decode") { + t.Errorf("non-JSON should produce decode error; got %v", err) + } +} + +// truncate is internal but worth a smoke test — log payloads use it. +func TestTruncate(t *testing.T) { + if got := truncate("short", 10); got != "short" { + t.Errorf("truncate short = %q, want unchanged", got) + } + got := truncate("a long enough string", 5) + if got != "a lon..." { + t.Errorf("truncate = %q, want 'a lon...'", got) + } +} + +// Smoke: ErrEmptyResult is exported and distinct from generic errors. +func TestErrEmptyResult_Identity(t *testing.T) { + if errors.Is(ErrEmptyResult, errors.New("other")) { + t.Error("ErrEmptyResult should not match arbitrary errors") + } +} diff --git a/internal/modules/lolschedule/format.go b/internal/modules/lolschedule/format.go new file mode 100644 index 0000000..582e0fa --- /dev/null +++ b/internal/modules/lolschedule/format.go @@ -0,0 +1,262 @@ +package lolschedule + +import ( + "fmt" + "html" + "sort" + "strings" + "time" +) + +// leagueOrder lists the most-prestigious tournaments first. Anything not +// in this list is rendered in alphabetical order after the known ones. +var leagueOrder = []string{ + "worlds", + "msi", + "first_stand", + "lck", + "lpl", + "lec", + "lcs", + "lcp", + "cblol-brazil", + "emea_masters", +} + +// majorLeagueSlugs filters the lolesports response down to the headline +// tournaments most viewers care about. Without this filter the API +// returns 135+ events/week and replies blow past Telegram's 4096-char limit. +var majorLeagueSlugs = map[string]bool{ + "lck": true, + "lpl": true, + "lec": true, + "lcs": true, + "worlds": true, + "msi": true, + "first_stand": true, + "lcp": true, + "cblol-brazil": true, + "emea_masters": true, +} + +// FilterMajor keeps only events whose league slug is in the major-league +// allowlist. Exposed for cron/handler reuse. +func FilterMajor(events []ScheduleEvent) []ScheduleEvent { + out := make([]ScheduleEvent, 0, len(events)) + for _, e := range events { + if majorLeagueSlugs[e.League.Slug] { + out = append(out, e) + } + } + return out +} + +// formatIctTime returns "HH:MM" in ICT. +func formatIctTime(t time.Time) string { + d := t.In(IctLocation) + return fmt.Sprintf("%02d:%02d", d.Hour(), d.Minute()) +} + +// formatIctDayLabel returns "Mon Sep 12" in ICT. +func formatIctDayLabel(t time.Time) string { + weekdays := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} + months := []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} + d := t.In(IctLocation) + return fmt.Sprintf("%s %s %d", weekdays[d.Weekday()], months[d.Month()-1], d.Day()) +} + +// ictDayKey returns "YYYY-MM-DD" in ICT — used to group week events by day. +func ictDayKey(t time.Time) string { + d := t.In(IctLocation) + return fmt.Sprintf("%04d-%02d-%02d", d.Year(), int(d.Month()), d.Day()) +} + +// teamLabel picks the best short identifier for a team. Empty team → "TBD". +func teamLabel(t Team) string { + if t.Code != "" { + return t.Code + } + if t.Name != "" { + return t.Name + } + return "TBD" +} + +// formatEventLine renders one match as a single line. Already HTML-safe; +// caller can join with "\n" inside a league section. +func formatEventLine(e ScheduleEvent) string { + var t1, t2 Team + if len(e.Match.Teams) > 0 { + t1 = e.Match.Teams[0] + } + if len(e.Match.Teams) > 1 { + t2 = e.Match.Teams[1] + } + t1Label := html.EscapeString(teamLabel(t1)) + t2Label := html.EscapeString(teamLabel(t2)) + block := "" + if e.BlockName != "" { + block = " (" + html.EscapeString(e.BlockName) + ")" + } + bo := "" + if e.Match.Strategy.Count > 0 { + bo = fmt.Sprintf(" · Bo%d", e.Match.Strategy.Count) + } + + switch e.State { + case "completed": + var w1, w2 int + if t1.Result != nil { + w1 = t1.Result.GameWins + } + if t2.Result != nil { + w2 = t2.Result.GameWins + } + left := t1Label + if t1.Result != nil && t1.Result.Outcome == "win" { + left = "" + t1Label + "" + } + right := t2Label + if t2.Result != nil && t2.Result.Outcome == "win" { + right = "" + t2Label + "" + } + return fmt.Sprintf("✅ %s %d–%d %s%s%s", left, w1, w2, right, bo, block) + case "inProgress": + var w1, w2 int + if t1.Result != nil { + w1 = t1.Result.GameWins + } + if t2.Result != nil { + w2 = t2.Result.GameWins + } + return fmt.Sprintf("🔴 LIVE %s %d–%d %s%s%s", t1Label, w1, w2, t2Label, bo, block) + default: + t, err := time.Parse(time.RFC3339, e.StartTime) + if err != nil { + t = time.Time{} + } + return fmt.Sprintf("🕒 %s %s vs %s%s%s", formatIctTime(t), t1Label, t2Label, bo, block) + } +} + +// leagueGroup is a per-league bucket used by the formatters. +type leagueGroup struct { + Slug string + Name string + Events []ScheduleEvent +} + +// groupByLeague preserves leagueOrder for known slugs, then alphabetises +// unknowns by display name. Stable across calls. +func groupByLeague(events []ScheduleEvent) []leagueGroup { + bySlug := map[string]*leagueGroup{} + for _, e := range events { + slug := e.League.Slug + if slug == "" { + slug = "unknown" + } + name := e.League.Name + if name == "" { + name = slug + } + g, ok := bySlug[slug] + if !ok { + g = &leagueGroup{Slug: slug, Name: name} + bySlug[slug] = g + } + g.Events = append(g.Events, e) + } + + knownIdx := map[string]int{} + for i, slug := range leagueOrder { + knownIdx[slug] = i + } + var known, unknown []leagueGroup + for slug, g := range bySlug { + if _, ok := knownIdx[slug]; ok { + known = append(known, *g) + } else { + unknown = append(unknown, *g) + } + } + sort.Slice(known, func(i, j int) bool { + return knownIdx[known[i].Slug] < knownIdx[known[j].Slug] + }) + sort.Slice(unknown, func(i, j int) bool { + return unknown[i].Name < unknown[j].Name + }) + return append(known, unknown...) +} + +// renderLeagueSection renders header + lines for one league. +func renderLeagueSection(g leagueGroup) string { + lines := make([]string, len(g.Events)) + for i, e := range g.Events { + lines[i] = formatEventLine(e) + } + return "" + html.EscapeString(g.Name) + "\n" + strings.Join(lines, "\n") +} + +// RenderToday renders the today reply — grouped by league. day may be any +// instant on the target ICT day. +func RenderToday(events []ScheduleEvent, day time.Time) string { + header := "LoL — " + html.EscapeString(formatIctDayLabel(day)) + " (ICT)" + if len(events) == 0 { + return header + "\nNo matches today." + } + groups := groupByLeague(events) + sections := make([]string, len(groups)) + for i, g := range groups { + sections[i] = renderLeagueSection(g) + } + return header + "\n\n" + strings.Join(sections, "\n\n") +} + +// RenderWeek renders the next-7-days reply — grouped by league → day. +// `to` is exclusive (the start of the day after the range), so the label +// uses to-1. +func RenderWeek(events []ScheduleEvent, from, to time.Time) string { + fromLbl := html.EscapeString(formatIctDayLabel(from)) + toLbl := html.EscapeString(formatIctDayLabel(to.Add(-time.Millisecond))) + header := "LoL — " + fromLbl + " → " + toLbl + " (ICT)" + if len(events) == 0 { + return header + "\nNo matches this week." + } + + leagueBlocks := make([]string, 0, len(events)) + for _, league := range groupByLeague(events) { + // Group this league's events by ICT day. + type dayBucket struct { + Label string + Lines []string + } + days := map[string]*dayBucket{} + for _, e := range league.Events { + t, err := time.Parse(time.RFC3339, e.StartTime) + if err != nil { + continue + } + key := ictDayKey(t) + d, ok := days[key] + if !ok { + d = &dayBucket{Label: formatIctDayLabel(t)} + days[key] = d + } + d.Lines = append(d.Lines, formatEventLine(e)) + } + // Sort day keys chronologically (lexical works since YYYY-MM-DD). + keys := make([]string, 0, len(days)) + for k := range days { + keys = append(keys, k) + } + sort.Strings(keys) + daySections := make([]string, len(keys)) + for i, k := range keys { + d := days[k] + daySections[i] = "" + html.EscapeString(d.Label) + "\n" + strings.Join(d.Lines, "\n") + } + block := "" + html.EscapeString(league.Name) + "\n" + strings.Join(daySections, "\n") + leagueBlocks = append(leagueBlocks, block) + } + return header + "\n\n" + strings.Join(leagueBlocks, "\n\n") +} diff --git a/internal/modules/lolschedule/format_test.go b/internal/modules/lolschedule/format_test.go new file mode 100644 index 0000000..713028d --- /dev/null +++ b/internal/modules/lolschedule/format_test.go @@ -0,0 +1,194 @@ +package lolschedule + +import ( + "strings" + "testing" + "time" +) + +func mkEvent(state, slug, name, t1Code, t2Code, startISO string) ScheduleEvent { + return ScheduleEvent{ + StartTime: startISO, + State: state, + League: League{Slug: slug, Name: name}, + Match: Match{ + Teams: []Team{{Code: t1Code}, {Code: t2Code}}, + Strategy: Strategy{Type: "bestOf", Count: 3}, + }, + } +} + +func TestFormatEventLine_Unstarted(t *testing.T) { + e := mkEvent("unstarted", "lck", "LCK", "T1", "GEN", "2026-05-09T05:00:00Z") + got := formatEventLine(e) + if !strings.Contains(got, "🕒") { + t.Errorf("missing clock emoji: %q", got) + } + if !strings.Contains(got, "T1 vs GEN") { + t.Errorf("missing team labels: %q", got) + } + if !strings.Contains(got, "Bo3") { + t.Errorf("missing Bo3: %q", got) + } + // 05:00 UTC == 12:00 ICT. + if !strings.Contains(got, "12:00") { + t.Errorf("ICT time wrong; got %q (expected 12:00)", got) + } +} + +func TestFormatEventLine_Completed_BoldsWinner(t *testing.T) { + winResult := &struct { + Outcome string `json:"outcome,omitempty"` + GameWins int `json:"gameWins,omitempty"` + }{Outcome: "win", GameWins: 3} + loseResult := &struct { + Outcome string `json:"outcome,omitempty"` + GameWins int `json:"gameWins,omitempty"` + }{Outcome: "loss", GameWins: 1} + e := ScheduleEvent{ + StartTime: "2026-05-09T05:00:00Z", + State: "completed", + League: League{Slug: "lck", Name: "LCK"}, + Match: Match{ + Teams: []Team{ + {Code: "T1", Result: winResult}, + {Code: "GEN", Result: loseResult}, + }, + Strategy: Strategy{Count: 5}, + }, + } + got := formatEventLine(e) + if !strings.Contains(got, "✅") { + t.Errorf("missing completed emoji: %q", got) + } + if !strings.Contains(got, "T1") { + t.Errorf("winner not bolded: %q", got) + } + if !strings.Contains(got, "3–1") { + t.Errorf("score missing: %q", got) + } + if strings.Contains(got, "GEN") { + t.Errorf("loser should not be bolded: %q", got) + } +} + +func TestFormatEventLine_InProgress(t *testing.T) { + w := &struct { + Outcome string `json:"outcome,omitempty"` + GameWins int `json:"gameWins,omitempty"` + }{GameWins: 1} + e := ScheduleEvent{ + StartTime: "2026-05-09T05:00:00Z", + State: "inProgress", + League: League{Slug: "lck"}, + Match: Match{ + Teams: []Team{{Code: "T1", Result: w}, {Code: "GEN", Result: w}}, + Strategy: Strategy{Count: 5}, + }, + } + got := formatEventLine(e) + if !strings.Contains(got, "🔴 LIVE") { + t.Errorf("missing LIVE marker: %q", got) + } + if !strings.Contains(got, "1–1") { + t.Errorf("score missing: %q", got) + } +} + +func TestRenderToday_GroupsByLeagueInOrder(t *testing.T) { + day := time.Date(2026, 5, 9, 0, 0, 0, 0, IctLocation) + events := []ScheduleEvent{ + mkEvent("unstarted", "lcs", "LCS", "TL", "C9", "2026-05-09T18:00:00Z"), + mkEvent("unstarted", "lck", "LCK", "T1", "GEN", "2026-05-09T05:00:00Z"), + mkEvent("unstarted", "lpl", "LPL", "JDG", "BLG", "2026-05-09T08:00:00Z"), + } + got := RenderToday(events, day) + // LEAGUE_ORDER puts LCK before LPL before LCS. + idxLck := strings.Index(got, "LCK") + idxLpl := strings.Index(got, "LPL") + idxLcs := strings.Index(got, "LCS") + if idxLck < 0 || idxLpl < 0 || idxLcs < 0 { + t.Fatalf("missing league section; got:\n%s", got) + } + if idxLck >= idxLpl || idxLpl >= idxLcs { + t.Errorf("league order wrong: lck=%d lpl=%d lcs=%d\n%s", idxLck, idxLpl, idxLcs, got) + } + // Header in ICT. + if !strings.Contains(got, "LoL — Sat May 9 (ICT)") { + t.Errorf("header wrong: %q", got) + } +} + +func TestRenderToday_EmptyShowsNoMatches(t *testing.T) { + day := time.Date(2026, 5, 9, 0, 0, 0, 0, IctLocation) + got := RenderToday(nil, day) + if !strings.Contains(got, "No matches today.") { + t.Errorf("empty render missing 'No matches today.': %q", got) + } +} + +func TestRenderWeek_GroupsByLeagueAndDay(t *testing.T) { + from := time.Date(2026, 5, 9, 0, 0, 0, 0, IctLocation) + to := from.AddDate(0, 0, 7) + events := []ScheduleEvent{ + mkEvent("unstarted", "lck", "LCK", "T1", "GEN", "2026-05-09T05:00:00Z"), + mkEvent("unstarted", "lck", "LCK", "DK", "KT", "2026-05-10T05:00:00Z"), + } + got := RenderWeek(events, from, to) + if !strings.Contains(got, "LCK") { + t.Errorf("missing LCK section: %q", got) + } + // Both days should appear under LCK. + if !strings.Contains(got, "Sat May 9") { + t.Errorf("missing Sat May 9: %q", got) + } + if !strings.Contains(got, "Sun May 10") { + t.Errorf("missing Sun May 10: %q", got) + } +} + +func TestFilterMajor(t *testing.T) { + events := []ScheduleEvent{ + {League: League{Slug: "lck"}}, + {League: League{Slug: "lpl"}}, + {League: League{Slug: "tcl"}}, // Turkish league — not in allowlist + {League: League{Slug: "lja"}}, // Japan academy — not in allowlist + {League: League{Slug: "msi"}}, + } + got := FilterMajor(events) + if len(got) != 3 { + t.Errorf("filtered count = %d, want 3", len(got)) + } + for _, e := range got { + if e.League.Slug == "tcl" || e.League.Slug == "lja" { + t.Errorf("non-major league leaked: %s", e.League.Slug) + } + } +} + +func TestFormatEventLine_EscapesUserStrings(t *testing.T) { + e := ScheduleEvent{ + StartTime: "2026-05-09T05:00:00Z", + State: "unstarted", + BlockName: "