Files
miti99bot/internal/modules/lol/api_client.go
T

339 lines
12 KiB
Go

// Package lol serves LoL esports match schedules via lolesports.com's
// persisted API plus a daily push to subscribers.
//
// Endpoint: https://esports-api.lolesports.com/persisted/gw/getSchedule
// Auth: x-api-key header — the public key embedded in lolesports.com's web
// client (no registration). If Riot ever rotates it, lift the new value
// from their public JS bundle.
//
// Cache strategy: live-first fetches with a KV-backed 60-minute stale fallback
// for current schedule windows.
package lol
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"unicode/utf8"
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/storage"
)
const (
apiURL = "https://esports-api.lolesports.com/persisted/gw/getSchedule"
// apiKey is the public lolesports.com web client key (not a secret).
// gosec flags it as a hardcoded credential; the value is shipped in
// Riot's own public web bundle and serves the live site too.
// #nosec G101
apiKey = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z"
userAgent = "miti99bot/0.1 (https://t.me/miti99bot)"
// staleMaxAge: how long to fall back to a cached payload when the
// upstream call fails outright.
staleMaxAge = 60 * 60 * time.Second
// httpTimeout: keep upstream calls bounded so a hung lolesports edge
// can't hold a 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.
type Team struct {
Name string `json:"name,omitempty" bson:"name,omitempty"`
Code string `json:"code,omitempty" bson:"code,omitempty"`
Image string `json:"image,omitempty" bson:"image,omitempty"`
Result *TeamResult `json:"result,omitempty" bson:"result,omitempty"`
Record *TeamRecord `json:"record,omitempty" bson:"record,omitempty"`
}
// TeamResult is a team's per-match outcome (named so tests and the bson decoder
// share one type).
type TeamResult struct {
Outcome string `json:"outcome,omitempty" bson:"outcome,omitempty"` // "win" or "loss"
GameWins int `json:"gameWins,omitempty" bson:"gameWins,omitempty"`
}
// TeamRecord is a team's running series record.
type TeamRecord struct {
Wins int `json:"wins,omitempty" bson:"wins,omitempty"`
Losses int `json:"losses,omitempty" bson:"losses,omitempty"`
}
// League holds the league-section-header info on each event.
type League struct {
Name string `json:"name,omitempty" bson:"name,omitempty"`
Slug string `json:"slug,omitempty" bson:"slug,omitempty"`
Image string `json:"image,omitempty" bson:"image,omitempty"`
}
// Strategy is the bestOf descriptor (Bo1, Bo3, Bo5).
type Strategy struct {
Type string `json:"type,omitempty" bson:"type,omitempty"`
Count int `json:"count,omitempty" bson:"count,omitempty"`
}
// Match is the inner match metadata.
type Match struct {
ID string `json:"id,omitempty" bson:"id,omitempty"`
Teams []Team `json:"teams,omitempty" bson:"teams,omitempty"`
Strategy Strategy `json:"strategy,omitempty" bson:"strategy,omitempty"`
}
// ScheduleEvent is one upcoming or past match. State is "unstarted",
// "inProgress", or "completed". Type is set to "show" for pre/post-show
// segments which we filter out.
type ScheduleEvent struct {
StartTime string `json:"startTime" bson:"startTime"`
State string `json:"state,omitempty" bson:"state,omitempty"`
Type string `json:"type,omitempty" bson:"type,omitempty"`
BlockName string `json:"blockName,omitempty" bson:"blockName,omitempty"`
League League `json:"league,omitempty" bson:"league,omitempty"`
Match Match `json:"match,omitempty" bson:"match,omitempty"`
}
// schedulePage is the inner shape of an upstream response.
type schedulePage struct {
Data struct {
Schedule struct {
Events []ScheduleEvent `json:"events"`
Pages struct {
Newer string `json:"newer,omitempty"`
Older string `json:"older,omitempty"`
} `json:"pages,omitempty"`
} `json:"schedule"`
} `json:"data"`
}
// cacheRecord is the store value: fetch timestamp + events. The Mongo store
// owns updatedAt separately; LoL uses ts for cache freshness in every backend.
type cacheRecord struct {
Ts int64 `json:"ts" bson:"ts"` // ms-since-epoch when fetched
Events []ScheduleEvent `json:"events" bson:"events"`
}
// 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).
type Client struct {
HTTP *http.Client
URL string // override for tests; empty falls back to apiURL
}
// httpClient returns the client to use, or a sensible default.
func (c *Client) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: httpTimeout}
}
func (c *Client) baseURL() string {
if c.URL != "" {
return c.URL
}
return apiURL
}
// fetchSchedulePage retrieves one page of events. pageToken can be either a
// forward (`pages.newer`) or backward (`pages.older`) cursor — the upstream
// uses the same query param for both directions. Returns the page's events
// (sorted ascending by startTime) plus both cursor tokens for further
// navigation in either direction.
func (c *Client) fetchSchedulePage(ctx context.Context, pageToken string) ([]ScheduleEvent, string, string, error) {
u, err := url.Parse(c.baseURL())
if err != nil {
return nil, "", "", fmt.Errorf("lol parse url: %w", err)
}
q := u.Query()
q.Set("hl", "en-US")
if pageToken != "" {
q.Set("pageToken", pageToken)
}
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, "", "", fmt.Errorf("lol build request: %w", err)
}
req.Header.Set("x-api-key", apiKey)
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, "", "", fmt.Errorf("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)
}
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)
}
var page schedulePage
if err := json.Unmarshal(body, &page); err != nil {
return nil, "", "", fmt.Errorf("lol decode: %w", err)
}
// Drop pre/post-show segments; they aren't matches.
out := make([]ScheduleEvent, 0, len(page.Data.Schedule.Events))
for _, e := range page.Data.Schedule.Events {
if e.Type == "show" {
continue
}
out = append(out, e)
}
return out, page.Data.Schedule.Pages.Newer, page.Data.Schedule.Pages.Older, nil
}
// earliestStart returns the first parseable startTime in a page that is sorted
// ascending. ok=false when no event has a valid timestamp.
func earliestStart(events []ScheduleEvent) (time.Time, bool) {
for _, e := range events {
if t, err := time.Parse(time.RFC3339, e.StartTime); err == nil {
return t, true
}
}
return time.Time{}, false
}
// latestStart returns the last parseable startTime in a page that is sorted
// ascending. ok=false when no event has a valid timestamp.
func latestStart(events []ScheduleEvent) (time.Time, bool) {
for i := len(events) - 1; i >= 0; i-- {
if t, err := time.Parse(time.RFC3339, events[i].StartTime); err == nil {
return t, true
}
}
return time.Time{}, false
}
// fetchEventsInRange covers [from, to) by walking both pagination directions
// from the default page (anchored at "now"). Walks older pages until the
// earliest collected event is ≤ from, then walks newer pages until the
// latest is ≥ to. Page budget caps both directions combined to bound
// upstream calls during dense weeks.
func (c *Client) fetchEventsInRange(ctx context.Context, from, to time.Time, maxPages int) ([]ScheduleEvent, error) {
if maxPages <= 0 {
maxPages = 8
}
events, newer, older, err := c.fetchSchedulePage(ctx, "")
if err != nil {
return nil, err
}
collected := events
pages := 1
// Walk older while the window extends before what we've collected.
for pages < maxPages && older != "" {
if t, ok := earliestStart(collected); ok && !t.After(from) {
break
}
olderEvents, _, prevOlder, err := c.fetchSchedulePage(ctx, older)
if err != nil {
return nil, err
}
// Each page is ascending and disjoint from the next, so prepending
// preserves overall ascending order.
collected = append(olderEvents, collected...)
older = prevOlder
pages++
}
// Walk newer while the window extends past what we've collected.
for pages < maxPages && newer != "" {
if t, ok := latestStart(collected); ok && !t.Before(to) {
break
}
newerEvents, nextNewer, _, err := c.fetchSchedulePage(ctx, newer)
if err != nil {
return nil, err
}
collected = append(collected, newerEvents...)
newer = nextNewer
pages++
}
out := make([]ScheduleEvent, 0, len(collected))
for _, e := range collected {
t, err := time.Parse(time.RFC3339, e.StartTime)
if err != nil {
continue
}
if !t.Before(from) && t.Before(to) {
out = append(out, e)
}
}
return out, nil
}
// cacheKey is `matches:<from-iso>:<to-iso>` — a stable key for a date range.
func cacheKey(from, to time.Time) string {
return "matches:" + from.UTC().Format(time.RFC3339) + ":" + to.UTC().Format(time.RFC3339)
}
// GetEventsLive fetches the requested range directly from upstream without
// consulting or writing the fallback cache.
func (c *Client) GetEventsLive(ctx context.Context, from, to time.Time) ([]ScheduleEvent, error) {
return c.fetchEventsInRange(ctx, from, to, 3)
}
// GetEventsWithFallback is live-first. It always tries upstream, writes a
// successful response to cache, and uses a recent cached response only when the
// upstream call fails.
func (c *Client) GetEventsWithFallback(ctx context.Context, cache CacheStore, from, to time.Time) ([]ScheduleEvent, error) {
key := cacheKey(from, to)
now := time.Now().UTC().UnixMilli()
cached, _, cacheErr := cache.Get(ctx, key)
hasCached := cacheErr == nil
events, fetchErr := c.GetEventsLive(ctx, from, to)
if fetchErr == nil {
rec := cacheRecord{Ts: now, Events: events}
if err := cache.Put(ctx, key, rec); err != nil {
log.Warn("lol_kv_put_fail", "err", err)
}
return events, nil
}
// Upstream failed — fall back to stale cache if recent enough.
if hasCached && now-cached.Ts < staleMaxAge.Milliseconds() {
log.Warn("lol_stale_fallback", "err", fetchErr)
return cached.Events, nil
}
return nil, fetchErr
}
// 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).
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
cut := maxLen
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "..."
}
// ErrEmptyResult is reserved for explicit "no events" scenarios where the
// fetch succeeded but returned zero matches. Currently unused outside tests
// but kept exported so callers can distinguish from network errors.
var ErrEmptyResult = errors.New("lol: no events in range")