feat(lol): replace schedule source with PandaScore API

Swap the lol module upstream from the lolesports.com gql persisted-query
client to PandaScore REST (/lol/matches, Bearer LOL_PANDASCORE_TOKEN,
free tier 1000 req/h). The gql transport broke whenever Riot redeployed
their frontend; PandaScore is a stable versioned contract.

ScheduleEvent, formatters, cron, and the bson cache shape are unchanged:
only the transport and response mapping moved. PandaScore league slugs
canonicalize to the existing major-league allowlist; results join to
opponents by team_id so reversed arrays cannot swap scores; outcomes are
declared only once upstream commits a winner, preserving the
score-pending rendering. A still-full final page now logs
lol_page_budget_exhausted and the live page budget covers 500 raw
matches per window.

Missing token short-circuits with lol_token_missing before any upstream
call; the 60-minute stale cache still covers outages. Document the new
env var and cancel the superseded Leaguepedia score-enrichment plan.
This commit is contained in:
tiennm99 committed 2026-08-05 17:39:20 +07:00
1 parent d50dd3f2f5
commit 58c42c312b
11 files changed
+814 -310

No files matched your search

+5
View File
@@ -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.
+1
View File
@@ -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
+211 -160
View File
@@ -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.<hash>.js, and copy the `id`
// next to `"name":"homeEvents"`.
// One request shape covers past, running, and upcoming matches:
//
// GET /lol/matches?range[begin_at]=<from>,<to>&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
+196 -99
View File
@@ -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("<html>not json</html>"))
}))
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")
}
}
+9 -9
View File
@@ -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) != ""
}
+33 -40
View File
@@ -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)
@@ -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.
@@ -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]=<fromISO>,<toISO>&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.
@@ -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.
@@ -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).
@@ -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]=<from>,<to>`
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.