diff --git a/.env.example b/.env.example index 867436f..4cd3d66 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,11 @@ ADMIN_IDS= # Commit in Build" disabled so Docker layer cache survives across commits. # Local `docker compose up` has none, so deploynotify reports "unknown". +# PandaScore API token for the lol module's schedule fetches (free tier at +# https://app.pandascore.co/dashboard, no credit card). Secret — never logged. +# Without it every /lol* fetch fails; the stale cache covers ≤60 min. +LOL_PANDASCORE_TOKEN= + # Optional /wheelofnames GIF renderer. Standard deployment: # https://github.com/tiennm99/wheelofnames. Leave blank to fall back to text # selection. diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index a737699..5f07fea 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -35,6 +35,7 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in. | `ADMIN_IDS` | optional | CSV of admin ids (renamed from `ADMIN_USER_IDS`) | | `WHEELOFNAMES_API_URL` | optional | full `/api/gif` endpoint for remote `/wheelofnames` GIF rendering | | `WHEELOFNAMES_API_TOKEN` | optional | bearer token matching the wheelofnames service `API_TOKEN` | +| `LOL_PANDASCORE_TOKEN` | ✅ for lol module | PandaScore API token (free tier) — secret, never logged; without it every `/lol*` fetch fails (stale cache may still serve briefly) | **Leave UNSET on self-host:** `KV_PROVIDER`, `PORT`, `TELEGRAM_WEBHOOK_SECRET`, and `GOLD_VNAPP_API_KEY`. Stock, coin, and gold URL diff --git a/internal/modules/lol/api_client.go b/internal/modules/lol/api_client.go index 6c4c9e0..364befb 100644 --- a/internal/modules/lol/api_client.go +++ b/internal/modules/lol/api_client.go @@ -1,32 +1,41 @@ -// Package lol serves LoL esports match schedules via lolesports.com's -// GraphQL gateway plus a daily push to subscribers. +// Package lol serves LoL esports match schedules via the PandaScore REST +// API plus a daily push to subscribers. // -// Endpoint: https://lolesports.com/api/gql — the same-origin proxy the -// lolesports.com web client uses. It accepts only persisted operations: -// the request carries no query text, just the operation name plus a -// pre-registered ID in the persistedQuery extension. The legacy -// esports-api.lolesports.com REST API and its public x-api-key were -// retired by Riot in 2026 (403 for everyone), so there is no key to ship. +// Endpoint: https://api.pandascore.co/lol/matches +// Auth: Bearer token from the LOL_PANDASCORE_TOKEN env var (free tier, +// 1000 requests/hour — orders of magnitude above this module's needs). +// PandaScore replaced the lolesports.com gql persisted-query transport, +// which broke whenever Riot redeployed their frontend; before that, Riot +// retired the original public esports-api.lolesports.com key outright. // -// If Riot redeploys the frontend with a changed homeEvents operation, the -// registered ID rotates and the gateway answers PERSISTED_QUERY_NOT_IN_LIST. -// To refresh: load lolesports.com, find the webpack runtime's chunk-hash map -// for chunk 29 (the persisted-operations manifest, `loadManifest` in the -// bundle), download /_next/static/chunks/29..js, and copy the `id` -// next to `"name":"homeEvents"`. +// One request shape covers past, running, and upcoming matches: +// +// GET /lol/matches?range[begin_at]=,&sort=begin_at&per_page=100&page=N +// +// The response is a top-level JSON array. The range's upper bound is +// inclusive upstream, so callers rely on the client-side [from, to) filter +// in fetchEventsInRange for exact window semantics. +// +// PandaScore league slugs (league-of-legends-lck-champions-korea, …) are +// canonicalized to the slugs format.go's allowlist and ordering were built +// on (lck, lpl, …) via leagueSlugMap; unmapped leagues pass through and the +// major-league filter drops them naturally. // // Cache strategy: live-first fetches with a KV-backed 60-minute stale // fallback for current schedule windows. package lol import ( - "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" + "net/url" + "os" + "strconv" + "strings" "time" "unicode/utf8" @@ -35,33 +44,55 @@ import ( ) const ( - apiURL = "https://lolesports.com/api/gql" - // homeEventsID is the gateway-registered persisted-query ID for the web - // client's homeEvents operation (schedule events by date window). Not a - // hash we compute — the value comes from the frontend's operations - // manifest and must be re-extracted if Riot rotates it (see package doc). - homeEventsID = "7246add6f577cf30b304e651bf9e25fc6a41fe49aeafb0754c16b5778060fc0a" - // The gateway rejects requests without Apollo client-awareness headers - // ("No client headers set"), so we mirror the web client's name and send - // our own version string. - clientName = "Esports Web" - clientVersion = "miti99bot/0.1" - userAgent = "miti99bot/0.1 (https://t.me/miti99bot)" - // pageSize is the events-per-page the gateway accepts; 100 covers a full - // day in one page and a dense week in a handful. + apiURL = "https://api.pandascore.co/lol/matches" + // tokenEnv is the name of the env var holding the token, not a + // credential itself. + tokenEnv = "LOL_PANDASCORE_TOKEN" // #nosec G101 + + userAgent = "miti99bot/0.1 (https://t.me/miti99bot)" + // pageSize is PandaScore's per_page maximum; 100 covers a dense day in + // one request and a full week in a couple. pageSize = 100 // 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 + // httpTimeout: keep upstream calls bounded so a hung upstream edge // can't hold a worker goroutine indefinitely. httpTimeout = 8 * time.Second ) -// Team is one side of a match. JSON shape matches the lolesports response. -// bson tags mirror the json names exactly: this tree is persisted inside -// cacheRecord, so the store must read those keys back verbatim — not the -// driver's lowercased default. +// leagueSlugMap canonicalizes PandaScore league slugs to the slugs the +// formatters were built on (format.go's majorLeagueSlugs / leagueOrder). +// Discovered live from /lol/leagues — see the plan's phase-01 findings. +// lta-north also maps to lcs: it is the LTA-era NA top flight; only the +// slug is canonicalized, the display name still passes through. +var leagueSlugMap = map[string]string{ + "league-of-legends-lck-champions-korea": "lck", + "league-of-legends-lpl-china": "lpl", + "league-of-legends-lec": "lec", + "league-of-legends-lcs": "lcs", + "league-of-legends-lta-north": "lcs", + "league-of-legends-world-championship": "worlds", + "league-of-legends-mid-invitational": "msi", + "league-of-legends-first-stand": "first_stand", + "league-of-legends-esports-world-cup": "ewc_lol", + "league-of-legends-lcp": "lcp", + "league-of-legends-cblol-brazil": "cblol-brazil", + "league-of-legends-emea-masters": "emea_masters", +} + +// canonicalLeagueSlug maps a PandaScore slug to the module's canonical slug, +// passing unknown slugs through unchanged (FilterMajor drops them). +func canonicalLeagueSlug(psSlug string) string { + if s, ok := leagueSlugMap[psSlug]; ok { + return s + } + return psSlug +} + +// Team is one side of a match. bson tags mirror the json names exactly: +// this tree is persisted inside cacheRecord, so the store must read those +// keys back verbatim — not the driver's lowercased default. type Team struct { Name string `json:"name,omitempty" bson:"name,omitempty"` Code string `json:"code,omitempty" bson:"code,omitempty"` @@ -104,9 +135,8 @@ type Match struct { } // 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. This struct is the module's stable shape: -// formatters and the bson cache read it, so the GraphQL response is mapped +// "inProgress", or "completed". This struct is the module's stable shape: +// formatters and the bson cache read it, so the upstream response is mapped // into it rather than leaking the transport shape. type ScheduleEvent struct { StartTime string `json:"startTime" bson:"startTime"` @@ -117,49 +147,92 @@ type ScheduleEvent struct { Match Match `json:"match,omitempty" bson:"match,omitempty"` } -// gqlEvent is one event as the gateway returns it. Teams live in matchTeams -// (not match.teams), so it converts into the stable ScheduleEvent shape. -type gqlEvent struct { - StartTime string `json:"startTime"` - State string `json:"state"` - Type string `json:"type"` - BlockName string `json:"blockName"` - League League `json:"league"` - Match Match `json:"match"` - MatchTeams []Team `json:"matchTeams"` +// psMatch is one match as PandaScore returns it (top-level array element). +type psMatch struct { + ID int64 `json:"id"` + BeginAt string `json:"begin_at"` + Status string `json:"status"` + NumberOfGames int `json:"number_of_games"` + WinnerID *int64 `json:"winner_id"` + League struct { + Name string `json:"name"` + Slug string `json:"slug"` + ImageURL string `json:"image_url"` + } `json:"league"` + Tournament struct { + Name string `json:"name"` + } `json:"tournament"` + Opponents []struct { + Opponent struct { + ID int64 `json:"id"` + Acronym string `json:"acronym"` + Name string `json:"name"` + ImageURL string `json:"image_url"` + } `json:"opponent"` + } `json:"opponents"` + Results []struct { + TeamID int64 `json:"team_id"` + Score int `json:"score"` + } `json:"results"` } -func (e gqlEvent) toScheduleEvent() ScheduleEvent { - m := e.Match - m.Teams = e.MatchTeams - return ScheduleEvent{ - StartTime: e.StartTime, - State: e.State, - Type: e.Type, - BlockName: e.BlockName, - League: e.League, - Match: m, +// psStateMap translates PandaScore match statuses to the module's states. +// Anything absent (canceled, postponed) is dropped by toScheduleEvent. +var psStateMap = map[string]string{ + "not_started": "unstarted", + "running": "inProgress", + "finished": "completed", +} + +// toScheduleEvent maps a PandaScore match into the stable ScheduleEvent +// shape. ok=false drops the match (unknown status). Results are joined to +// opponents strictly by team_id — upstream order of the two arrays is not +// guaranteed to agree. Outcome is only declared once the match is finished +// and upstream has committed a winner; that keeps scoreIsPublished's +// "score pending" semantics intact for finished-but-unresolved series. +func (m psMatch) toScheduleEvent() (ScheduleEvent, bool) { + state, ok := psStateMap[m.Status] + if !ok { + return ScheduleEvent{}, false } -} - -// gqlResponse is the outer shape of a gateway response. GraphQL transports -// errors in-band on HTTP 200, so both branches must be checked. -type gqlResponse struct { - Data struct { - Esports struct { - Events []gqlEvent `json:"events"` - Pages struct { - Older string `json:"older,omitempty"` - Newer string `json:"newer,omitempty"` - } `json:"pages"` - } `json:"esports"` - } `json:"data"` - Errors []struct { - Message string `json:"message"` - Extensions struct { - Code string `json:"code"` - } `json:"extensions"` - } `json:"errors"` + scoreByTeam := make(map[int64]int, len(m.Results)) + for _, r := range m.Results { + scoreByTeam[r.TeamID] = r.Score + } + teams := make([]Team, 0, len(m.Opponents)) + for _, o := range m.Opponents { + t := Team{ + Name: o.Opponent.Name, + Code: o.Opponent.Acronym, + Image: o.Opponent.ImageURL, + } + res := &TeamResult{GameWins: scoreByTeam[o.Opponent.ID]} + if m.Status == "finished" && m.WinnerID != nil { + if o.Opponent.ID == *m.WinnerID { + res.Outcome = "win" + } else { + res.Outcome = "loss" + } + } + t.Result = res + teams = append(teams, t) + } + return ScheduleEvent{ + StartTime: m.BeginAt, + State: state, + Type: "match", + BlockName: m.Tournament.Name, + League: League{ + Name: m.League.Name, + Slug: canonicalLeagueSlug(m.League.Slug), + Image: m.League.ImageURL, + }, + Match: Match{ + ID: strconv.FormatInt(m.ID, 10), + Teams: teams, + Strategy: Strategy{Type: "bestOf", Count: m.NumberOfGames}, + }, + }, true } // cacheRecord is the store value: fetch timestamp + events. The Mongo store @@ -172,9 +245,9 @@ type cacheRecord struct { // CacheStore is the typed store for schedule cache records. type CacheStore = storage.DocStore[cacheRecord] -// 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). +// Client is the PandaScore API client. Default zero-value uses a bounded +// default HTTP client; 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 @@ -195,113 +268,88 @@ func (c *Client) baseURL() string { return apiURL } -// gqlRequest is the persisted-operation request body: no query text, just -// the operation name, variables, and the registered ID. -type gqlRequest struct { - OperationName string `json:"operationName"` - Variables map[string]any `json:"variables"` - Extensions struct { - PersistedQuery struct { - Version int `json:"version"` - Sha256Hash string `json:"sha256Hash"` - } `json:"persistedQuery"` - } `json:"extensions"` -} - -// fetchEventsPage retrieves one page of events for a date window. The -// gateway's eventDateStart/eventDateEnd are calendar dates with unspecified -// timezone semantics, so the window is padded a day on each side and callers -// filter to the exact [from, to) instants afterwards. pageToken continues a -// prior page's `pages.newer` cursor. Events come back ascending by startTime. -func (c *Client) fetchEventsPage(ctx context.Context, from, to time.Time, pageToken string) ([]ScheduleEvent, string, error) { - vars := map[string]any{ - "hl": "en-US", - "sport": []string{"lol"}, - "eventDateStart": from.UTC().AddDate(0, 0, -1).Format("2006-01-02"), - "eventDateEnd": to.UTC().AddDate(0, 0, 1).Format("2006-01-02"), - "eventType": "match", - "pageSize": pageSize, - } - if pageToken != "" { - vars["pageToken"] = pageToken - } - reqBody := gqlRequest{OperationName: "homeEvents", Variables: vars} - reqBody.Extensions.PersistedQuery.Version = 1 - reqBody.Extensions.PersistedQuery.Sha256Hash = homeEventsID - payload, err := json.Marshal(reqBody) - if err != nil { - return nil, "", fmt.Errorf("lol encode request: %w", err) +// fetchEventsPage retrieves one page of matches overlapping [from, to]. +// Returns the mapped events plus the raw upstream item count — pagination +// must be driven by the raw count, since status-dropped matches shrink the +// mapped slice below per_page on otherwise-full pages. +func (c *Client) fetchEventsPage(ctx context.Context, from, to time.Time, page int) ([]ScheduleEvent, int, error) { + token := strings.TrimSpace(os.Getenv(tokenEnv)) + if token == "" { + log.Error("lol_token_missing", "env", tokenEnv) + return nil, 0, fmt.Errorf("lol %s not set", tokenEnv) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL(), bytes.NewReader(payload)) + u, err := url.Parse(c.baseURL()) if err != nil { - return nil, "", fmt.Errorf("lol build request: %w", err) + return nil, 0, fmt.Errorf("lol parse url: %w", err) } - req.Header.Set("Content-Type", "application/json") + q := u.Query() + q.Set("range[begin_at]", from.UTC().Format(time.RFC3339)+","+to.UTC().Format(time.RFC3339)) + q.Set("sort", "begin_at") + q.Set("per_page", strconv.Itoa(pageSize)) + q.Set("page", strconv.Itoa(page)) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, 0, fmt.Errorf("lol build request: %w", err) + } + // Bearer header only — never the token-in-URL variant, so request URLs + // stay safe to log. + req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") - req.Header.Set("apollographql-client-name", clientName) - req.Header.Set("apollographql-client-version", clientVersion) req.Header.Set("User-Agent", userAgent) resp, err := c.httpClient().Do(req) if err != nil { - return nil, "", fmt.Errorf("lol do: %w", err) + return nil, 0, fmt.Errorf("lol do: %w", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { - return nil, "", fmt.Errorf("lol read: %w", err) + return nil, 0, fmt.Errorf("lol read: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { log.Warn("lol_fetch", "status", resp.StatusCode, "body", truncate(string(body), 500)) - return nil, "", fmt.Errorf("lol API HTTP %d", resp.StatusCode) + return nil, 0, fmt.Errorf("lol API HTTP %d", resp.StatusCode) } - var page gqlResponse - if err := json.Unmarshal(body, &page); err != nil { - return nil, "", fmt.Errorf("lol decode: %w", err) + var matches []psMatch + if err := json.Unmarshal(body, &matches); err != nil { + return nil, 0, fmt.Errorf("lol decode: %w", err) } - if len(page.Errors) > 0 { - first := page.Errors[0] - // A rotated persisted-query ID is an operational event, not a blip: - // every call fails until the constant is refreshed, so make the log - // unmistakable (see package doc for the refresh procedure). - if first.Extensions.Code == "PERSISTED_QUERY_NOT_IN_LIST" { - log.Error("lol_persisted_query_rotated", "msg", first.Message) - } else { - log.Warn("lol_fetch", "gql_code", first.Extensions.Code, "gql_err", truncate(first.Message, 500)) + out := make([]ScheduleEvent, 0, len(matches)) + for _, m := range matches { + if e, ok := m.toScheduleEvent(); ok { + out = append(out, e) } - return nil, "", fmt.Errorf("lol API gql error %s: %s", first.Extensions.Code, first.Message) } - // Drop pre/post-show segments; they aren't matches. - out := make([]ScheduleEvent, 0, len(page.Data.Esports.Events)) - for _, e := range page.Data.Esports.Events { - if e.Type == "show" { - continue - } - out = append(out, e.toScheduleEvent()) - } - return out, page.Data.Esports.Pages.Newer, nil + return out, len(matches), nil } -// fetchEventsInRange covers [from, to) with a date-bounded query, following -// `pages.newer` cursors when a window holds more than one page. Page budget -// bounds upstream calls during dense weeks. +// fetchEventsInRange covers [from, to) with a range-bounded query, walking +// pages while upstream keeps returning full ones. Page budget bounds +// upstream calls during dense weeks. The exact [from, to) filter is applied +// here because PandaScore's range upper bound is inclusive. func (c *Client) fetchEventsInRange(ctx context.Context, from, to time.Time, maxPages int) ([]ScheduleEvent, error) { if maxPages <= 0 { maxPages = 8 } var collected []ScheduleEvent - pageToken := "" - for page := 0; page < maxPages; page++ { - events, newer, err := c.fetchEventsPage(ctx, from, to, pageToken) + for page := 1; page <= maxPages; page++ { + events, rawCount, err := c.fetchEventsPage(ctx, from, to, page) if err != nil { return nil, err } collected = append(collected, events...) - if newer == "" { + if rawCount < pageSize { break } - pageToken = newer + // A still-full final page means the window holds more matches than + // the page budget covers; with ascending sort the tail of the window + // would silently vanish from replies, so leave a diagnostic trail. + if page == maxPages { + log.Warn("lol_page_budget_exhausted", "maxPages", maxPages, "from", from, "to", to) + } } out := make([]ScheduleEvent, 0, len(collected)) @@ -323,9 +371,12 @@ func cacheKey(from, to time.Time) string { } // GetEventsLive fetches the requested range directly from upstream without -// consulting or writing the fallback cache. +// consulting or writing the fallback cache. Five pages = 500 raw matches; +// PandaScore returns ~270/week across all its leagues, so a dense in-season +// week still fits with room to spare, and quota cost is negligible at +// 1000 req/h. func (c *Client) GetEventsLive(ctx context.Context, from, to time.Time) ([]ScheduleEvent, error) { - return c.fetchEventsInRange(ctx, from, to, 3) + return c.fetchEventsInRange(ctx, from, to, 5) } // GetEventsWithFallback is live-first. It always tries upstream, writes a @@ -356,10 +407,10 @@ func (c *Client) GetEventsWithFallback(ctx context.Context, cache CacheStore, fr } // truncate clips a string to a rune-boundary prefix whose byte length is -// <= maxLen, appending "..." if cut. Keeps log output bounded — lolesports -// occasionally returns multi-MB error pages, and team/player names mix in -// Korean/Chinese characters that a raw byte slice would split mid-codepoint -// (producing replacement glyphs in CloudWatch). +// <= maxLen, appending "..." if cut. Keeps log output bounded — upstream +// error pages can be large, and team names mix in Korean/Chinese characters +// that a raw byte slice would split mid-codepoint (producing replacement +// glyphs in CloudWatch). func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s diff --git a/internal/modules/lol/api_client_test.go b/internal/modules/lol/api_client_test.go index 0d8ddf6..f2ba904 100644 --- a/internal/modules/lol/api_client_test.go +++ b/internal/modules/lol/api_client_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -14,6 +15,13 @@ import ( "github.com/tiennm99/miti99bot/internal/storage" ) +// setTestToken puts a dummy PandaScore token in the environment so client +// calls pass the token presence check. httptest servers ignore it. +func setTestToken(t *testing.T) { + t.Helper() + t.Setenv(tokenEnv, "test-token") +} + // newCacheStore returns a fresh typed cache store backed by an in-memory // collection — the per-test equivalent of what the factory wires in production. func newCacheStore() CacheStore { @@ -35,33 +43,39 @@ func mkServer(t *testing.T, body string) (*httptest.Server, *int32) { return srv, &count } -const sampleBody = `{ - "data": { - "esports": { - "events": [ - { - "startTime": "2026-05-09T05:00:00Z", - "state": "unstarted", - "type": "match", - "league": {"slug": "lck", "name": "LCK"}, - "match": {"strategy":{"count":3}}, - "matchTeams": [{"code":"T1"},{"code":"GEN"}] - }, - { - "startTime": "2026-05-09T08:00:00Z", - "state": "unstarted", - "type": "show", - "league": {"slug": "lck", "name": "LCK"}, - "match": {"strategy":{}}, - "matchTeams": [] - } - ], - "pages": {"newer": null} - } +// sampleBody is a PandaScore /lol/matches response: a top-level array. +// Second entry carries a status outside the mapped vocabulary and must be +// dropped. +const sampleBody = `[ + { + "id": 1001, + "begin_at": "2026-05-09T05:00:00Z", + "status": "not_started", + "number_of_games": 3, + "winner_id": null, + "league": {"name": "LCK", "slug": "league-of-legends-lck-champions-korea", "image_url": ""}, + "tournament": {"name": "Regular Season"}, + "opponents": [ + {"opponent": {"id": 1, "acronym": "T1", "name": "T1", "image_url": ""}}, + {"opponent": {"id": 2, "acronym": "GEN", "name": "Gen.G", "image_url": ""}} + ], + "results": [{"team_id": 1, "score": 0}, {"team_id": 2, "score": 0}] + }, + { + "id": 1002, + "begin_at": "2026-05-09T08:00:00Z", + "status": "canceled", + "number_of_games": 3, + "winner_id": null, + "league": {"name": "LCK", "slug": "league-of-legends-lck-champions-korea", "image_url": ""}, + "tournament": {"name": "Regular Season"}, + "opponents": [], + "results": [] } -}` +]` func TestGetEventsWithFallback_FirstHitFetchesUpstreamAndCaches(t *testing.T) { + setTestToken(t) srv, count := mkServer(t, sampleBody) c := &Client{HTTP: srv.Client(), URL: srv.URL} cache := newCacheStore() @@ -73,13 +87,13 @@ func TestGetEventsWithFallback_FirstHitFetchesUpstreamAndCaches(t *testing.T) { t.Fatalf("first fetch: %v", err) } if len(events) != 1 { - t.Errorf("events = %d, want 1 (show filtered out)", len(events)) + t.Errorf("events = %d, want 1 (canceled dropped)", len(events)) } if events[0].League.Slug != "lck" { - t.Errorf("event slug = %q, want lck", events[0].League.Slug) + t.Errorf("event slug = %q, want lck (canonicalized)", events[0].League.Slug) } if got := events[0].Match.Teams; len(got) != 2 || got[0].Code != "T1" { - t.Errorf("teams not mapped from matchTeams: %+v", got) + t.Errorf("teams not mapped from opponents: %+v", got) } if atomic.LoadInt32(count) != 1 { t.Errorf("upstream calls = %d, want 1", *count) @@ -94,6 +108,7 @@ func TestGetEventsWithFallback_FirstHitFetchesUpstreamAndCaches(t *testing.T) { } func TestGetEventsWithFallback_AlwaysFetchesWhenUpstreamAvailable(t *testing.T) { + setTestToken(t) srv, count := mkServer(t, sampleBody) c := &Client{HTTP: srv.Client(), URL: srv.URL} cache := newCacheStore() @@ -112,6 +127,7 @@ func TestGetEventsWithFallback_AlwaysFetchesWhenUpstreamAvailable(t *testing.T) } func TestGetEventsWithFallback_StaleFallback(t *testing.T) { + setTestToken(t) // Prime the typed cache store with a stale-but-still-fresh-enough record. cache := newCacheStore() from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) @@ -143,6 +159,7 @@ func TestGetEventsWithFallback_StaleFallback(t *testing.T) { } func TestGetEventsWithFallback_HardFailureWhenNoCache(t *testing.T) { + setTestToken(t) cache := newCacheStore() from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) to := from.Add(24 * time.Hour) @@ -160,6 +177,7 @@ func TestGetEventsWithFallback_HardFailureWhenNoCache(t *testing.T) { } func TestGetEventsLive_DoesNotWriteCache(t *testing.T) { + setTestToken(t) srv, _ := mkServer(t, sampleBody) c := &Client{HTTP: srv.Client(), URL: srv.URL} cache := newCacheStore() @@ -174,90 +192,170 @@ func TestGetEventsLive_DoesNotWriteCache(t *testing.T) { } } -func TestFetchEventsPage_DropsShowEvents(t *testing.T) { - srv, _ := mkServer(t, sampleBody) +func TestFetchEventsPage_TokenMissing(t *testing.T) { + t.Setenv(tokenEnv, " ") // whitespace-only counts as missing + srv, count := mkServer(t, sampleBody) + c := &Client{HTTP: srv.Client(), URL: srv.URL} + from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) + _, _, err := c.fetchEventsPage(context.Background(), from, from.Add(24*time.Hour), 1) + if err == nil || !strings.Contains(err.Error(), tokenEnv) { + t.Errorf("missing token should error mentioning %s; got %v", tokenEnv, err) + } + if atomic.LoadInt32(count) != 0 { + t.Errorf("upstream must not be called without a token; calls = %d", *count) + } +} + +// TestFetchEventsPage_RequestShape asserts the wire contract: Bearer auth +// header (never token-in-URL) plus range/sort/per_page/page params. +func TestFetchEventsPage_RequestShape(t *testing.T) { + setTestToken(t) + var gotAuth, gotRange, gotSort, gotPerPage, gotPage string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + q := r.URL.Query() + gotRange = q.Get("range[begin_at]") + gotSort = q.Get("sort") + gotPerPage = q.Get("per_page") + gotPage = q.Get("page") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() c := &Client{HTTP: srv.Client(), URL: srv.URL} from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) - events, _, err := c.fetchEventsPage(context.Background(), from, from.Add(24*time.Hour), "") - if err != nil { + to := from.Add(24 * time.Hour) + if _, _, err := c.fetchEventsPage(context.Background(), from, to, 2); err != nil { t.Fatal(err) } - for _, e := range events { - if e.Type == "show" { - t.Errorf("show event leaked: %+v", e) - } + if gotAuth != "Bearer test-token" { + t.Errorf("Authorization = %q, want Bearer test-token", gotAuth) + } + if want := "2026-05-09T00:00:00Z,2026-05-10T00:00:00Z"; gotRange != want { + t.Errorf("range[begin_at] = %q, want %q", gotRange, want) + } + if gotSort != "begin_at" || gotPerPage != "100" || gotPage != "2" { + t.Errorf("sort/per_page/page = %q/%q/%q, want begin_at/100/2", gotSort, gotPerPage, gotPage) } } func TestFetchEventsPage_NonJSONErrors(t *testing.T) { + setTestToken(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} from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) - _, _, err := c.fetchEventsPage(context.Background(), from, from.Add(24*time.Hour), "") + _, _, err := c.fetchEventsPage(context.Background(), from, from.Add(24*time.Hour), 1) if err == nil || !strings.Contains(err.Error(), "decode") { t.Errorf("non-JSON should produce decode error; got %v", err) } } -// TestFetchEventsPage_GraphQLErrorIsFetchError guards the in-band error path: -// the gateway answers HTTP 200 with an `errors` array (e.g. when Riot rotates -// the persisted-query ID), and that must surface as a fetch failure — not be -// cached as an empty schedule. -func TestFetchEventsPage_GraphQLErrorIsFetchError(t *testing.T) { - body := `{"errors":[{"message":"Persisted query 'x' not found in the persisted query list","extensions":{"code":"PERSISTED_QUERY_NOT_IN_LIST"}}]}` - srv, _ := mkServer(t, body) - c := &Client{HTTP: srv.Client(), URL: srv.URL} - from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) - _, _, err := c.fetchEventsPage(context.Background(), from, from.Add(24*time.Hour), "") - if err == nil || !strings.Contains(err.Error(), "PERSISTED_QUERY_NOT_IN_LIST") { - t.Errorf("gql error should surface as fetch error with code; got %v", err) +// TestToScheduleEvent_ScoreAndWinnerMapping guards the results join: results +// arrive keyed by team_id in arbitrary order relative to opponents, and the +// winner flag must translate into win/loss outcomes only when finished. +func TestToScheduleEvent_ScoreAndWinnerMapping(t *testing.T) { + winner := int64(2) + body := fmt.Sprintf(`{ + "id": 42, + "begin_at": "2026-05-09T05:00:00Z", + "status": "finished", + "number_of_games": 5, + "winner_id": %d, + "league": {"name": "LPL", "slug": "league-of-legends-lpl-china", "image_url": ""}, + "tournament": {"name": "Playoffs"}, + "opponents": [ + {"opponent": {"id": 1, "acronym": "JDG", "name": "JD Gaming", "image_url": ""}}, + {"opponent": {"id": 2, "acronym": "BLG", "name": "Bilibili Gaming", "image_url": ""}} + ], + "results": [{"team_id": 2, "score": 3}, {"team_id": 1, "score": 1}] + }`, winner) + var m psMatch + if err := json.Unmarshal([]byte(body), &m); err != nil { + t.Fatal(err) + } + e, ok := m.toScheduleEvent() + if !ok { + t.Fatal("finished match must map") + } + if e.State != "completed" || e.BlockName != "Playoffs" || e.League.Slug != "lpl" { + t.Errorf("state/block/slug = %q/%q/%q", e.State, e.BlockName, e.League.Slug) + } + if e.Match.Strategy.Count != 5 { + t.Errorf("Bo = %d, want 5", e.Match.Strategy.Count) + } + jdg, blg := e.Match.Teams[0], e.Match.Teams[1] + // Results were listed BLG-first; the join by team_id must still credit + // JDG with 1 and BLG with 3. + if jdg.Result.GameWins != 1 || jdg.Result.Outcome != "loss" { + t.Errorf("JDG result = %+v, want 1/loss", jdg.Result) + } + if blg.Result.GameWins != 3 || blg.Result.Outcome != "win" { + t.Errorf("BLG result = %+v, want 3/win", blg.Result) } } -// requestVars decodes the persisted-operation POST body a test server -// received and returns its variables map. -func requestVars(t *testing.T, r *http.Request) map[string]any { - t.Helper() - var req struct { - Variables map[string]any `json:"variables"` +// TestToScheduleEvent_FinishedWithoutWinnerStaysPending: finished but no +// winner_id → no outcome declared → formatters render "score pending". +func TestToScheduleEvent_FinishedWithoutWinnerStaysPending(t *testing.T) { + m := psMatch{Status: "finished", BeginAt: "2026-05-09T05:00:00Z"} + m.Opponents = make([]struct { + Opponent struct { + ID int64 `json:"id"` + Acronym string `json:"acronym"` + Name string `json:"name"` + ImageURL string `json:"image_url"` + } `json:"opponent"` + }, 2) + m.Opponents[0].Opponent.ID = 1 + m.Opponents[1].Opponent.ID = 2 + e, ok := m.toScheduleEvent() + if !ok { + t.Fatal("finished match must map") } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode request body: %v", err) + if scoreIsPublished(e.Match.Teams[0], e.Match.Teams[1]) { + t.Errorf("no winner_id must leave score unpublished") } - return req.Variables } -// TestFetchEventsInRange_WalksNewerPages guards multi-page windows: when a -// date window holds more events than one page, the fetcher must follow -// `pages.newer` cursors until the gateway reports no further page. -// -// Server simulates 2 pages: -// - no pageToken → Mon May 18 + Tue May 19 (returns newer=page2) -// - pageToken=page2 → Wed May 20 (no newer) -func TestFetchEventsInRange_WalksNewerPages(t *testing.T) { - page1Body := `{"data":{"esports":{"events":[ - {"startTime":"2026-05-18T05:00:00Z","state":"completed","type":"match","league":{"slug":"lck","name":"LCK"},"match":{"strategy":{"count":3}},"matchTeams":[{"code":"KT"},{"code":"DK"}]}, - {"startTime":"2026-05-19T05:00:00Z","state":"completed","type":"match","league":{"slug":"lck","name":"LCK"},"match":{"strategy":{"count":3}},"matchTeams":[{"code":"NS"},{"code":"BRO"}]} - ],"pages":{"newer":"page2","older":null}}}}` - page2Body := `{"data":{"esports":{"events":[ - {"startTime":"2026-05-20T05:00:00Z","state":"completed","type":"match","league":{"slug":"lck","name":"LCK"},"match":{"strategy":{"count":3}},"matchTeams":[{"code":"GEN"},{"code":"T1"}]} - ],"pages":{"newer":null,"older":"page1"}}}}` +// TestFetchEventsInRange_WalksFullPages guards pagination: a full raw page +// (100 items) must trigger a fetch of the next page, driven by the RAW count +// — status-dropped matches must not end the walk early. +func TestFetchEventsInRange_WalksFullPages(t *testing.T) { + setTestToken(t) + // Page 1: 100 raw matches (2 canceled), page 2: 1 match. + page1 := make([]map[string]any, 0, pageSize) + for i := 0; i < pageSize; i++ { + status := "not_started" + if i < 2 { + status = "canceled" + } + page1 = append(page1, map[string]any{ + "id": 2000 + i, "begin_at": "2026-05-18T05:00:00Z", "status": status, + "number_of_games": 1, + "league": map[string]any{"name": "LCK", "slug": "league-of-legends-lck-champions-korea"}, + "tournament": map[string]any{"name": "Regular Season"}, + "opponents": []any{}, "results": []any{}, + }) + } + page1JSON, _ := json.Marshal(page1) + page2JSON := `[{"id": 3000, "begin_at": "2026-05-19T05:00:00Z", "status": "not_started", + "number_of_games": 1, + "league": {"name": "LCK", "slug": "league-of-legends-lck-champions-korea"}, + "tournament": {"name": "Regular Season"}, "opponents": [], "results": []}]` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - vars := requestVars(t, r) - token, _ := vars["pageToken"].(string) - switch token { - case "": - _, _ = w.Write([]byte(page1Body)) - case "page2": - _, _ = w.Write([]byte(page2Body)) + switch r.URL.Query().Get("page") { + case "1": + _, _ = w.Write(page1JSON) + case "2": + _, _ = w.Write([]byte(page2JSON)) default: - t.Errorf("unexpected pageToken %q", token) + t.Errorf("unexpected page %q", r.URL.Query().Get("page")) w.WriteHeader(http.StatusBadRequest) } })) @@ -271,26 +369,32 @@ func TestFetchEventsInRange_WalksNewerPages(t *testing.T) { if err != nil { t.Fatalf("fetch: %v", err) } - if len(events) != 3 { - t.Errorf("events = %d, want 3 (Mon, Tue, Wed); got days=%v", len(events), eventDays(events)) + // 100 raw - 2 canceled + 1 on page 2. + if len(events) != 99 { + t.Errorf("events = %d, want 99", len(events)) } } -// TestFetchEventsInRange_FiltersToExactWindow guards the client-side [from, to) -// filter: the request pads the date window a day each side (the gateway's date -// params have fuzzy timezone semantics), so events outside the exact instants -// must be dropped locally. +// TestFetchEventsInRange_FiltersToExactWindow guards the client-side +// [from, to) filter: PandaScore's range upper bound is inclusive, so an +// event starting exactly at `to` comes back and must be dropped locally. func TestFetchEventsInRange_FiltersToExactWindow(t *testing.T) { - body := `{"data":{"esports":{"events":[ - {"startTime":"2026-05-17T23:00:00Z","state":"completed","type":"match","league":{"slug":"lck","name":"LCK"},"match":{"strategy":{"count":3}},"matchTeams":[{"code":"KT"},{"code":"DK"}]}, - {"startTime":"2026-05-18T05:00:00Z","state":"unstarted","type":"match","league":{"slug":"lck","name":"LCK"},"match":{"strategy":{"count":3}},"matchTeams":[{"code":"GEN"},{"code":"T1"}]}, - {"startTime":"2026-05-19T00:00:00Z","state":"unstarted","type":"match","league":{"slug":"lck","name":"LCK"},"match":{"strategy":{"count":3}},"matchTeams":[{"code":"NS"},{"code":"BRO"}]} - ],"pages":{"newer":null}}}}` + setTestToken(t) + body := `[ + {"id": 1, "begin_at": "2026-05-18T05:00:00Z", "status": "not_started", "number_of_games": 3, + "league": {"name": "LCK", "slug": "league-of-legends-lck-champions-korea"}, + "tournament": {"name": "Regular Season"}, + "opponents": [{"opponent": {"id": 1, "acronym": "GEN", "name": "Gen.G"}}], "results": []}, + {"id": 2, "begin_at": "2026-05-19T00:00:00Z", "status": "not_started", "number_of_games": 3, + "league": {"name": "LCK", "slug": "league-of-legends-lck-champions-korea"}, + "tournament": {"name": "Regular Season"}, + "opponents": [{"opponent": {"id": 2, "acronym": "T1", "name": "T1"}}], "results": []} + ]` srv, _ := mkServer(t, body) c := &Client{HTTP: srv.Client(), URL: srv.URL} from := time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC) - to := from.Add(24 * time.Hour) + to := time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC) // second event is exactly `to` events, err := c.fetchEventsInRange(context.Background(), from, to, 0) if err != nil { t.Fatalf("fetch: %v", err) @@ -318,10 +422,3 @@ func TestTruncate(t *testing.T) { 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/lol/format.go b/internal/modules/lol/format.go index 72f4321..dc55bf7 100644 --- a/internal/modules/lol/format.go +++ b/internal/modules/lol/format.go @@ -24,7 +24,7 @@ var leagueOrder = []string{ "emea_masters", } -// majorLeagueSlugs filters the lolesports response down to the headline +// majorLeagueSlugs filters the upstream schedule 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{ @@ -107,15 +107,15 @@ func seriesWins(t Team) int { // scoreIsPublished reports whether a finished series has a score we can quote. // -// lolesports drives `event.state` off the broadcast timeline and fills -// `result.outcome`/`result.gameWins` from a separate per-game ingestion path, -// so a match reads as "completed" for hours before (or without ever) gaining a -// score. In that window every team carries `{"outcome": null, "gameWins": 0}`, -// which a nil-check cannot catch because the object itself is present — and -// Go's zero value for the absent gameWins then renders as a literal 0. +// Upstream can mark a match completed before committing a result (PandaScore: +// `status: finished` with `winner_id` still null; the client maps that to +// teams whose Result carries no Outcome). In that window a nil-check cannot +// catch the gap because the Result object itself is present — and Go's zero +// value for gameWins would render as a 0–0 that nobody played. // -// An outcome on either side is enough: it proves the ingestion ran, so the -// gameWins alongside it are real even if the other side's result is sparse. +// An outcome on either side is enough: the client only declares outcomes once +// upstream has committed a winner, so the gameWins alongside it are real even +// if the other side's result is sparse. func scoreIsPublished(t1, t2 Team) bool { return declaredOutcome(t1) != "" || declaredOutcome(t2) != "" } diff --git a/internal/modules/lol/handlers_test.go b/internal/modules/lol/handlers_test.go index e18bc52..cb66cc5 100644 --- a/internal/modules/lol/handlers_test.go +++ b/internal/modules/lol/handlers_test.go @@ -19,6 +19,7 @@ import ( // Returns the recording bot and the subscriber store for inspection in tests. func installSchedule(t *testing.T, bodyJSON string, nowMs int64) (*testutil.RecordingBot, SubscriberStore) { t.Helper() + setTestToken(t) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(bodyJSON)) @@ -61,49 +62,41 @@ func installSchedule(t *testing.T, bodyJSON string, nowMs int64) (*testutil.Reco // "now" in handler tests. const fakeNowMs int64 = 1778328000000 // 2026-05-09T12:00:00Z -const todayBody = `{ - "data": { - "esports": { - "events": [ - { - "startTime": "2026-05-09T05:00:00Z", - "state": "unstarted", - "type": "match", - "league": {"slug": "lck", "name": "LCK"}, - "match": {"strategy":{"count":3}}, - "matchTeams": [{"code":"T1"},{"code":"GEN"}] - } - ], - "pages": {"newer": null} - } +const todayBody = `[ + { + "id": 1, "begin_at": "2026-05-09T05:00:00Z", "status": "not_started", "number_of_games": 3, + "league": {"name": "LCK", "slug": "league-of-legends-lck-champions-korea"}, + "tournament": {"name": "Regular Season"}, + "opponents": [ + {"opponent": {"id": 1, "acronym": "T1", "name": "T1"}}, + {"opponent": {"id": 2, "acronym": "GEN", "name": "Gen.G"}} + ], + "results": [] } -}` +]` -const futureBody = `{ - "data": { - "esports": { - "events": [ - { - "startTime": "2026-05-10T05:00:00Z", - "state": "unstarted", - "type": "match", - "league": {"slug": "lck", "name": "LCK"}, - "match": {"strategy":{"count":3}}, - "matchTeams": [{"code":"DK"},{"code":"KT"}] - }, - { - "startTime": "2026-05-12T08:00:00Z", - "state": "unstarted", - "type": "match", - "league": {"slug": "lpl", "name": "LPL"}, - "match": {"strategy":{"count":5}}, - "matchTeams": [{"code":"JDG"},{"code":"BLG"}] - } - ], - "pages": {"newer": null} - } +const futureBody = `[ + { + "id": 2, "begin_at": "2026-05-10T05:00:00Z", "status": "not_started", "number_of_games": 3, + "league": {"name": "LCK", "slug": "league-of-legends-lck-champions-korea"}, + "tournament": {"name": "Regular Season"}, + "opponents": [ + {"opponent": {"id": 3, "acronym": "DK", "name": "Dplus KIA"}}, + {"opponent": {"id": 4, "acronym": "KT", "name": "KT Rolster"}} + ], + "results": [] + }, + { + "id": 3, "begin_at": "2026-05-12T08:00:00Z", "status": "not_started", "number_of_games": 5, + "league": {"name": "LPL", "slug": "league-of-legends-lpl-china"}, + "tournament": {"name": "Playoffs"}, + "opponents": [ + {"opponent": {"id": 5, "acronym": "JDG", "name": "JD Gaming"}}, + {"opponent": {"id": 6, "acronym": "BLG", "name": "Bilibili Gaming"}} + ], + "results": [] } -}` +]` func TestHandleSchedule_DefaultsToToday(t *testing.T) { rb, _ := installSchedule(t, todayBody, fakeNowMs) diff --git a/plans/260726-0952-lol-leaguepedia-score-enrichment/plan.md b/plans/260726-0952-lol-leaguepedia-score-enrichment/plan.md index 8c19cbb..8dc8d04 100644 --- a/plans/260726-0952-lol-leaguepedia-score-enrichment/plan.md +++ b/plans/260726-0952-lol-leaguepedia-score-enrichment/plan.md @@ -1,11 +1,11 @@ --- title: "LoL Leaguepedia score enrichment" description: "Fill missing series scores from Leaguepedia when lolesports marks a match completed but publishes no result" -status: pending +status: cancelled priority: P2 branch: "main" tags: [lol, external-api, enrichment] -blockedBy: [] +blockedBy: [260805-1708-lol-pandascore-replacement] blocks: [] created: "2026-07-26T02:54:48.516Z" createdBy: "ck:plan" @@ -14,6 +14,13 @@ source: skill # LoL Leaguepedia score enrichment +> **Cancelled 2026-08-05.** Superseded by +> [260805-1708-lol-pandascore-replacement](../260805-1708-lol-pandascore-replacement/plan.md): +> the lol module's upstream moved to PandaScore, whose match `results` carry +> final series scores directly, absorbing this plan's enrichment job. The +> lolesports "completed without score" ingestion gap this plan targeted no +> longer applies to the new source. + ## Overview lolesports flips `event.state` to `completed` off the broadcast timeline but fills `result.outcome`/`result.gameWins` from a separate per-game ingestion path. When that path stalls, finished matches carry `{"outcome": null, "gameWins": 0}`. Commit `570ee94` made this render honestly as `☑️ … score pending`; this plan restores a real score by filling the gap from Leaguepedia. diff --git a/plans/260805-1708-lol-pandascore-replacement/phase-01-feasibility-and-mapping-discovery.md b/plans/260805-1708-lol-pandascore-replacement/phase-01-feasibility-and-mapping-discovery.md new file mode 100644 index 0000000..e787c7a --- /dev/null +++ b/plans/260805-1708-lol-pandascore-replacement/phase-01-feasibility-and-mapping-discovery.md @@ -0,0 +1,86 @@ +--- +phase: 1 +title: Feasibility and mapping discovery +status: completed +effort: 'S — curl session only, no production code' +--- + +# Phase 1: Feasibility and mapping discovery + +## Overview + +Answer every open question with the live token before writing code. **Done +2026-08-05** — all questions answered, no assumption broke badly enough to +reshape Phase 2. ~7 quota requests used. Findings below supersede the +original question list. + +## Findings (verified live 2026-08-05) + +1. **Auth + quota.** Bearer header works; `HTTP 200`; + `x-rate-limit-remaining: 999` after first call → 1000/h free tier + confirmed. Token in repo `.env` as `LOL_PANDASCORE_TOKEN` (51 chars); + `.env` is NOT shell-sourceable (unquoted `&` on line 11) — Go reads env + directly, irrelevant for production. +2. **Window recipe.** + `GET /lol/matches?range[begin_at]=,&sort=begin_at&per_page=100` + → 77 matches for a 2-day window, ascending. **Upper range bound is + INCLUSIVE** (event at exactly `to` returned) → client-side `[from,to)` + filter is mandatory, keep it. Pagination: `Link` headers rel=next/last + AND `page=N` both available → walk `page` while `len==per_page`. + Response is a **top-level JSON array** (no envelope). +3. **Status vocabulary** (observed): `not_started`, `running`, `finished`. + Map to `unstarted`/`inProgress`/`completed`; **drop any other status** + (canceled, postponed) defensively. +4. **League slug mapping** (from `/lol/leagues`, 133 leagues, 3 pages): + + | canonical (format.go) | PandaScore slug | id | + |---|---|---| + | lck | league-of-legends-lck-champions-korea | 293 | + | lpl | league-of-legends-lpl-china | 294 | + | lec | league-of-legends-lec | 4197 | + | lcs | league-of-legends-lcs | 4198 | + | lcs | league-of-legends-lta-north | 5345 | (LTA-era NA top flight; slug canonicalized, display name passes through) | + | worlds | league-of-legends-world-championship | 297 | + | msi | league-of-legends-mid-invitational | 300 | + | first_stand | league-of-legends-first-stand | 5369 | + | ewc_lol | league-of-legends-esports-world-cup | 5262 | + | lcp | league-of-legends-lcp | 5351 | + | cblol-brazil | league-of-legends-cblol-brazil | 302 | + | emea_masters | league-of-legends-emea-masters | 4996 | + + Active majors verified in live windows: lck, lpl, lec, cblol-brazil, lcp. +5. **Field shapes.** + - `begin_at`: RFC3339 `Z` — parses with existing `time.RFC3339`. + - `results[]`: `{team_id, score}` present in ALL states (0-0 upcoming, + 1-1 running, final on finished) → live 🔴 scores work. Join to + opponents strictly by `team_id`. + - `winner_id`: non-null only when finished → Outcome win/loss derived + from `status==finished && winner_id != nil`; finished without winner_id + → no outcome → existing `☑️ score pending` path (semantics preserved). + - `number_of_games` → Strategy.Count; `opponents[].opponent.{acronym,name,image_url}` + → Team.Code/Name/Image. + - **BlockName ← `tournament.name`** ("Regular Season", "Group Ascend", + "Playoffs") — closest analogue to old block labels. + - `name` is "MCN vs BAN" — redundant, unused. +6. **TBD.** 0 matches with <2 opponents in 100-match upcoming sample; + mapping must still tolerate `len(opponents)<2` (distant playoffs) — + `formatEventLine`/`teamLabel` already render "TBD" for missing teams. +7. **ToS.** Docs state Fixtures-Only plan free for all users; no explicit + attribution requirement found in developer docs. Low risk; see open + question in plan.md. + +## Success Criteria + +- [x] 200 with token; quota headers recorded; free-tier headroom confirmed +- [x] Date-window recipe verified incl. pagination behavior +- [x] Status vocabulary + mapping decided +- [x] League mapping table fully filled +- [x] BlockName source + TBD shape decided +- [x] ToS/attribution answer recorded (docs level; full ToS text unread) +- [x] Phase 2 adjustments: none structural — confirmed top-level array, + inclusive upper bound, results-in-all-states, lta-north dual mapping + +## Risk Assessment (resolved) + +- Worlds/MSI are ordinary leagues (297/300) — no serie-based lookup needed. +- EWC/First Stand exist as leagues (5262/5369) — no gaps. diff --git a/plans/260805-1708-lol-pandascore-replacement/phase-02-pandascore-client-rewrite.md b/plans/260805-1708-lol-pandascore-replacement/phase-02-pandascore-client-rewrite.md new file mode 100644 index 0000000..1e9be26 --- /dev/null +++ b/plans/260805-1708-lol-pandascore-replacement/phase-02-pandascore-client-rewrite.md @@ -0,0 +1,109 @@ +--- +phase: 2 +title: PandaScore client rewrite +status: completed +effort: 'M — transport swap + fixtures, same blast radius as b76d0ca' +dependencies: + - 1 +--- + +# Phase 2: PandaScore client rewrite + +## Overview + +Swap the transport in `internal/modules/lol/api_client.go` from the gql +persisted-query client to PandaScore REST, keeping `ScheduleEvent` and every +consumer (`format.go`, `handlers.go`, `cron.go`, cache) unchanged. Mirrors +the b76d0ca rewrite: transport + mapping only. + +## Requirements + +- Functional: same rendered output for day/week windows; scores on finished + series; major-league filtering intact via slug mapping from Phase 1. +- Non-functional: no live calls in tests; bounded timeouts (keep 8s); token + never logged; stale-cache fallback (60 min) preserved. + +## Architecture + +``` +GetEventsWithFallback / GetEventsLive (unchanged) + │ + ▼ +fetchEventsInRange(ctx, from, to, maxPages) (page-walk, [from,to) filter — unchanged shape) + │ + ▼ +fetchEventsPage(ctx, from, to, page) (NEW: GET api.pandascore.co/lol/matches + │ range[begin_at]=from,to & sort=begin_at + ▼ & per_page=100 & page=N +psMatch.toScheduleEvent() Authorization: Bearer $LOL_PANDASCORE_TOKEN) + │ + ▼ +ScheduleEvent (existing struct, bson cache shape untouched) +``` + +Mapping (finalize against Phase 1 findings): + +| ScheduleEvent | PandaScore | +|---|---| +| StartTime | `begin_at` (RFC3339) | +| State | `not_started→unstarted`, `running→inProgress`, `finished→completed`; drop canceled/postponed per Phase 1 | +| BlockName | source decided in Phase 1 (candidate `tournament.name`) | +| League.Slug/Name/Image | PandaScore league mapped → canonical slug via table; name/image passthrough | +| Match.Strategy.Count | `number_of_games` (Type: "bestOf") | +| Match.Teams[].Code/Name/Image | `opponents[].opponent.acronym/name/image_url` | +| Match.Teams[].Result | `results[]` matched to opponent by `team_id`; GameWins=score; Outcome win/loss from `winner_id` when `finished` | + +## Related Code Files + +- Modify: `internal/modules/lol/api_client.go` (transport, psMatch structs, + mapping, league slug table, token from env) +- Modify: `internal/modules/lol/api_client_test.go` (fixtures → PandaScore + shape; keep test matrix: cache-first, live-first, stale fallback, hard + failure, page walk, exact-window filter, non-JSON error; add: token-missing + error, canceled-match dropped, results/winner mapping) +- Modify: `internal/modules/lol/handlers_test.go` (`todayBody`/`futureBody` + fixtures → PandaScore shape) +- No changes: `format.go`, `handlers.go`, `cron.go`, `subscribers.go`, + `lol.go` wiring (Client zero-value still works; token read at request time) + +## Implementation Steps + +1. Define `psMatch`/`psOpponent`/`psResult`/`psLeague` structs + + `toScheduleEvent()` with the mapping table above. +2. Replace `fetchEventsPage`: build URL with `range[begin_at]`, `sort`, + `per_page`, `page`; Bearer header from `LOL_PANDASCORE_TOKEN` (read via + `os.Getenv` at call time, `strings.TrimSpace`d); keep `truncate`d body + logging on non-2xx; distinct log key `lol_token_missing` when env empty + (return error without calling upstream). +3. Rework `fetchEventsInRange` pagination: increment `page` while a full page + (`len==per_page`) returned, capped by maxPages; keep exact `[from,to)` + client-side filter (PandaScore range bounds semantics per Phase 1). +4. League slug mapping table (PandaScore slug → canonical) as package-level + map from Phase 1 findings; unmapped leagues pass through their PandaScore + slug (FilterMajor drops them naturally). +5. Update package doc comment: endpoint, auth, quota, mapping rationale, + token env var. +6. Rewrite test fixtures in PandaScore shape (raw JSON arrays — note: + `/lol/matches` returns a top-level array, not an envelope, per docs; + confirm in Phase 1). Keep httptest pattern; assert Bearer header + query + params in a request-inspection test. +7. Handle 401/403 (bad token) and 429 (quota) with clear log keys + (`lol_fetch` with status suffices; verify stale cache covers). +8. `gofmt`, `go test ./internal/modules/lol/`, then full gates. + +## Success Criteria + +- [ ] All existing test scenarios pass with PandaScore fixtures +- [ ] New tests: token-missing, canceled dropped, score/winner mapping, Bearer + header + range params asserted +- [ ] `rg lolesports internal/modules/lol` → only doc-comment history note or + nothing +- [ ] `go test ./...`, `go vet ./...`, `golangci-lint run` green + +## Risk Assessment + +- Top-level array vs envelope mismatch → caught by Phase 1 recipe. +- Opponent order vs results order mismatch → always join `results` by + `team_id`, never by index; test covers reversed order. +- Token leakage → never log request URL if token-in-URL auth is used; use + Bearer header only. diff --git a/plans/260805-1708-lol-pandascore-replacement/phase-03-wire-in-cleanup-and-supersede.md b/plans/260805-1708-lol-pandascore-replacement/phase-03-wire-in-cleanup-and-supersede.md new file mode 100644 index 0000000..798517e --- /dev/null +++ b/plans/260805-1708-lol-pandascore-replacement/phase-03-wire-in-cleanup-and-supersede.md @@ -0,0 +1,50 @@ +--- +phase: 3 +title: Wire-in cleanup and supersede +status: completed +effort: 'S — env docs, live smoke, plan bookkeeping' +dependencies: + - 2 +--- + +# Phase 3: Wire-in cleanup and supersede + +## Overview + +Deployment plumbing, live verification with the real token, documentation, +and cross-plan bookkeeping (cancel the superseded Leaguepedia plan). + +## Implementation Steps + +1. **Env plumbing.** Add `LOL_PANDASCORE_TOKEN` to the deployment environment + (wherever `GOLD_VNAPP_API_KEY` lives — user applies the secret; never + commit it). Confirm the bot process picks it up. +2. **Docs.** Update README / docs env-var table if one exists (check + `README.md`, `docs/`) with `LOL_PANDASCORE_TOKEN` (required for lol module + fetches; without it /lol replies degrade to fetch-error message). + Attribution note if Phase 1 ToS check requires it. +3. **Live smoke (temporary probe test, delete after):** today + this-week + windows through the real client; verify major leagues present, scores on + finished series, ICT rendering; check quota headers after the run. +4. **Supersede plan 260726-0952:** set `status: cancelled` in its `plan.md` + frontmatter + add one-line note: "Superseded by + 260805-1708-lol-pandascore-replacement — PandaScore results carry final + scores, absorbing this enrichment." Do not delete files. +5. **Full gates:** `go test ./...`, `go vet ./...`, `golangci-lint run`. +6. **Commit** (on user request, conventional message, e.g. + `feat(lol): replace schedule source with PandaScore API`). + +## Success Criteria + +- [ ] Live /lol and /lol_this_week verified with real token on the server +- [ ] Env var documented; secret nowhere in repo or logs +- [ ] Plan 260726-0952 marked cancelled with supersede pointer +- [ ] All gates green; probe test removed +- [ ] Commit created (user-approved) + +## Risk Assessment + +- Daily push (08:00 ICT cron) is the first unattended consumer — watch first + push after deploy; stale cache + error message paths already tested. +- Rollback: revert commit → b76d0ca gql client returns (its persisted ID may + need re-extraction per that commit's package doc). diff --git a/plans/260805-1708-lol-pandascore-replacement/plan.md b/plans/260805-1708-lol-pandascore-replacement/plan.md new file mode 100644 index 0000000..be5033a --- /dev/null +++ b/plans/260805-1708-lol-pandascore-replacement/plan.md @@ -0,0 +1,105 @@ +--- +title: Replace LoL schedule source with PandaScore +description: >- + Swap the lol module's upstream from the lolesports.com gql persisted-query + client to PandaScore's REST API as the sole schedule/results source +status: completed +priority: P2 +branch: main +tags: + - lol + - external-api + - migration +blockedBy: [] +blocks: [] +created: '2026-08-05T10:12:46.240Z' +createdBy: 'ck:plan' +source: skill +--- + +# Replace LoL schedule source with PandaScore + +## Overview + +Replace the lolesports.com `/api/gql` persisted-query transport (commit `b76d0ca`) +with PandaScore's REST API as the **only** upstream for the lol module. User +decision (2026-08-05): full replacement, not fallback — accepting the trade-off +that official-source data and the gql client are dropped in exchange for a +stable, versioned, documented API contract that does not break on Riot frontend +deploys. Free tier: 1000 req/h, Bearer token (user already has one). + +Public contract unchanged: `ScheduleEvent` shape, formatters, handlers, cron, +subscriber flows, and the 60-min stale-cache fallback all stay. Only the +transport + response mapping in `api_client.go` change, mirroring the b76d0ca +rewrite pattern. + +Supersedes plan `260726-0952-lol-leaguepedia-score-enrichment`: PandaScore's +match `results` carry final series scores directly, absorbing the +score-enrichment job. That plan is marked cancelled in Phase 3. + +Evidence: `plans/reports/research-260805-1627-stable-free-lol-schedule-source-report.md`. + +## Key design decisions + +- **One mapping table, client-side.** PandaScore league slugs differ from + Riot's; the client maps PandaScore league → existing canonical slug so + `format.go`'s `majorLeagueSlugs` allowlist and `leagueOrder` stay untouched. + Phase 1 discovers the real slugs with the live token. +- **Token via env** `LOL_PANDASCORE_TOKEN`, matching the + `GOLD_VNAPP_API_KEY` pattern (`internal/modules/gold/vnappmob_client.go:52`). + Missing token → fetch error at call time (stale cache may still serve); + never panic at startup. +- **Single window endpoint.** `GET /lol/matches?range[begin_at]=,` + covers past+running+upcoming in one call; page-walk `per_page=100`. + Exact `[from,to)` filtering stays client-side (proven pattern). +- **Status mapping**: `not_started→unstarted`, `running→inProgress`, + `finished→completed`; `canceled`/`postponed` dropped (Phase 1 verifies the + full status vocabulary). + +## Phases + +| Phase | Name | Status | +|-------|------|--------| +| 1 | [Feasibility and mapping discovery](./phase-01-feasibility-and-mapping-discovery.md) | Completed | +| 2 | [PandaScore client rewrite](./phase-02-pandascore-client-rewrite.md) | Completed | +| 3 | [Wire-in cleanup and supersede](./phase-03-wire-in-cleanup-and-supersede.md) | Completed | + +Phase 1 is a cheap kill/adjust gate (curl only, needs `LOL_PANDASCORE_TOKEN` +exported); Phases 2-3 must not start before its questions are answered. + +## Acceptance criteria + +- [x] `/lol`, `/lol_tomorrow`, `/lol_this_week`, `/lol_next_week` render from + PandaScore data with identical message format — live probe rendered a + real week: `🔴 LIVE JDG 1–0 LGD · Bo3 (Group Ascend)`, ICT day grouping +- [x] Major-league filter keeps working via slug mapping (LCK/LPL verified + live; minor leagues dropped) +- [x] Finished series show real scores from `results`; winner bolded + (unit-tested; outcome gated on `winner_id`) +- [x] No references to lolesports.com remain in module code beyond + doc-comment history; gql client fully removed +- [x] Missing/invalid token degrades to fetch error + stale cache + (`lol_token_missing`, no upstream call — unit-tested) +- [x] `go test ./...`, `go vet`, `golangci-lint` green; tests use `httptest` + fixtures in PandaScore shape, no live calls +- [x] Live smoke: today+tomorrow and this-week windows returned plausible + events incl. a live LPL match; quota 1000/h confirmed in Phase 1 +- [x] Plan 260726-0952 marked cancelled/superseded with pointer here + +Code review (code-reviewer subagent): all 9 criteria PASS, +DONE_WITH_CONCERNS; both concerns fixed same day (page-budget warn log + +live budget 3→5; bracket encoding verified by live probe; phantom test +removed). + +## Dependencies + +- Supersedes: `260726-0952-lol-leaguepedia-score-enrichment` (marked in Phase 3). +- External: PandaScore REST API, free tier, `LOL_PANDASCORE_TOKEN` (user holds token). + +## Open questions + +- PandaScore full ToS text unreviewed (developer docs show no attribution + requirement for the free Fixtures plan) — low risk, revisit if the bot's + audience grows. +- Deployment env (`Coolify`) needs `LOL_PANDASCORE_TOKEN` set by the owner + before the next deploy; local `.env` already has it.