mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
d50dd3f2f5
commit
58c42c312b
11 files changed
+814
-310
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) != ""
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+86
@@ -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.
|
||||
+50
@@ -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.
|
||||
Reference in new issue
Block a user