mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-10-03 09:13:36 +00:00
Correct comments that described the retired webhook transport, a removed /cron route, the old KV store and wrapper types, and behaviour that has since changed; drop plan and review labels; reword two startup log lines that overstated or misnamed what they report.
428 lines
15 KiB
Go
428 lines
15 KiB
Go
// Package lol serves LoL esports match schedules via the PandaScore REST
|
|
// API plus a daily push to subscribers.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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 document-store-backed 60-minute
|
|
// stale fallback for current schedule windows (today, tomorrow, this and next
|
|
// week, and the daily push). Explicit-date /lol lookups are always live.
|
|
package lol
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/tiennm99/miti99bot/internal/log"
|
|
"github.com/tiennm99/miti99bot/internal/storage"
|
|
)
|
|
|
|
const (
|
|
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 upstream edge
|
|
// can't hold a worker goroutine indefinitely.
|
|
httpTimeout = 8 * time.Second
|
|
)
|
|
|
|
// leagueSlugMap canonicalizes PandaScore league slugs to the slugs the
|
|
// formatters were built on (format.go's majorLeagueSlugs / leagueOrder).
|
|
// The PandaScore slugs were taken from the live /lol/leagues listing.
|
|
// 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",
|
|
}
|
|
|
|
// 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"`
|
|
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". 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"`
|
|
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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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
|
|
// 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 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
u, err := url.Parse(c.baseURL())
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("lol parse url: %w", err)
|
|
}
|
|
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("User-Agent", userAgent)
|
|
|
|
resp, err := c.httpClient().Do(req)
|
|
if err != nil {
|
|
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, 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, 0, fmt.Errorf("lol API HTTP %d", resp.StatusCode)
|
|
}
|
|
var matches []psMatch
|
|
if err := json.Unmarshal(body, &matches); err != nil {
|
|
return nil, 0, fmt.Errorf("lol decode: %w", err)
|
|
}
|
|
out := make([]ScheduleEvent, 0, len(matches))
|
|
for _, m := range matches {
|
|
if e, ok := m.toScheduleEvent(); ok {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out, len(matches), nil
|
|
}
|
|
|
|
// 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
|
|
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 rawCount < pageSize {
|
|
break
|
|
}
|
|
// 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))
|
|
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. 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, 5)
|
|
}
|
|
|
|
// 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 — 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 the logs).
|
|
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")
|