Files
tiennm99bot/internal/modules/lol/format.go
T
tiennm99 58c42c312b 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.
2026-08-05 17:39:20 +07:00

306 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lol
import (
"fmt"
"html"
"sort"
"strings"
"time"
)
// leagueOrder lists the most-prestigious tournaments first. Anything not
// in this list is rendered in alphabetical order after the known ones.
var leagueOrder = []string{
"worlds",
"msi",
"first_stand",
"ewc_lol",
"lck",
"lpl",
"lec",
"lcs",
"lcp",
"cblol-brazil",
"emea_masters",
}
// 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{
"lck": true,
"lpl": true,
"lec": true,
"lcs": true,
"worlds": true,
"msi": true,
"first_stand": true,
"ewc_lol": true,
"lcp": true,
"cblol-brazil": true,
"emea_masters": true,
}
// FilterMajor keeps only events whose league slug is in the major-league
// allowlist. Exposed for cron/handler reuse.
func FilterMajor(events []ScheduleEvent) []ScheduleEvent {
out := make([]ScheduleEvent, 0, len(events))
for _, e := range events {
if majorLeagueSlugs[e.League.Slug] {
out = append(out, e)
}
}
return out
}
// formatIctTime returns "HH:MM" in ICT.
func formatIctTime(t time.Time) string {
d := t.In(IctLocation)
return fmt.Sprintf("%02d:%02d", d.Hour(), d.Minute())
}
// formatIctDayLabel returns "Mon Sep 12" in ICT.
func formatIctDayLabel(t time.Time) string {
weekdays := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
months := []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
d := t.In(IctLocation)
return fmt.Sprintf("%s %s %d", weekdays[d.Weekday()], months[d.Month()-1], d.Day())
}
// ictDayKey returns "YYYY-MM-DD" in ICT — used to group week events by day.
func ictDayKey(t time.Time) string {
d := t.In(IctLocation)
return fmt.Sprintf("%04d-%02d-%02d", d.Year(), int(d.Month()), d.Day())
}
// teamLabel picks the best short identifier for a team. Empty team → "TBD".
func teamLabel(t Team) string {
if t.Code != "" {
return t.Code
}
if t.Name != "" {
return t.Name
}
return "TBD"
}
// declaredOutcome returns a team's upstream-declared series outcome ("win" or
// "loss"), or "" when upstream has not published one. Two distinct upstream
// shapes collapse to "": a missing `result` object, and the far more common
// `{"outcome": null, "gameWins": 0}`.
func declaredOutcome(t Team) string {
if t.Result == nil {
return ""
}
return t.Result.Outcome
}
// seriesWins returns a team's games won in the series, 0 when upstream has sent
// no result. Only meaningful once an outcome has been declared — see
// scoreIsPublished.
func seriesWins(t Team) int {
if t.Result == nil {
return 0
}
return t.Result.GameWins
}
// scoreIsPublished reports whether a finished series has a score we can quote.
//
// 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: 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) != ""
}
// formatEventLine renders one match as a single line. Already HTML-safe;
// caller can join with "\n" inside a league section.
func formatEventLine(e ScheduleEvent) string {
var t1, t2 Team
if len(e.Match.Teams) > 0 {
t1 = e.Match.Teams[0]
}
if len(e.Match.Teams) > 1 {
t2 = e.Match.Teams[1]
}
t1Label := html.EscapeString(teamLabel(t1))
t2Label := html.EscapeString(teamLabel(t2))
block := ""
if e.BlockName != "" {
block = " (" + html.EscapeString(e.BlockName) + ")"
}
bo := ""
if e.Match.Strategy.Count > 0 {
bo = fmt.Sprintf(" · Bo%d", e.Match.Strategy.Count)
}
switch e.State {
case "completed":
// Upstream says the series is over but has published no outcome, so we
// have no score to report. Show the matchup and say so rather than let
// the absent gameWins render as a 0–0 that nobody played.
if !scoreIsPublished(t1, t2) {
return fmt.Sprintf("☑️ %s vs %s%s%s · score pending", t1Label, t2Label, bo, block)
}
left := t1Label
if declaredOutcome(t1) == "win" {
left = "<b>" + t1Label + "</b>"
}
right := t2Label
if declaredOutcome(t2) == "win" {
right = "<b>" + t2Label + "</b>"
}
return fmt.Sprintf("✅ %s %d–%d %s%s%s",
left, seriesWins(t1), seriesWins(t2), right, bo, block)
case "inProgress":
// No published-score guard here: a live series legitimately sits at 0–0
// until its first game resolves, and upstream declares no outcome until
// the series ends.
return fmt.Sprintf("🔴 LIVE %s %d–%d %s%s%s",
t1Label, seriesWins(t1), seriesWins(t2), t2Label, bo, block)
default:
t, err := time.Parse(time.RFC3339, e.StartTime)
if err != nil {
t = time.Time{}
}
return fmt.Sprintf("🕒 %s %s vs %s%s%s", formatIctTime(t), t1Label, t2Label, bo, block)
}
}
// leagueGroup is a per-league bucket used by the formatters.
type leagueGroup struct {
Slug string
Name string
Events []ScheduleEvent
}
// groupByLeague preserves leagueOrder for known slugs, then alphabetises
// unknowns by display name. Stable across calls.
func groupByLeague(events []ScheduleEvent) []leagueGroup {
bySlug := map[string]*leagueGroup{}
for _, e := range events {
slug := e.League.Slug
if slug == "" {
slug = "unknown"
}
name := e.League.Name
if name == "" {
name = slug
}
g, ok := bySlug[slug]
if !ok {
g = &leagueGroup{Slug: slug, Name: name}
bySlug[slug] = g
}
g.Events = append(g.Events, e)
}
knownIdx := map[string]int{}
for i, slug := range leagueOrder {
knownIdx[slug] = i
}
var known, unknown []leagueGroup
for slug, g := range bySlug {
if _, ok := knownIdx[slug]; ok {
known = append(known, *g)
} else {
unknown = append(unknown, *g)
}
}
sort.Slice(known, func(i, j int) bool {
return knownIdx[known[i].Slug] < knownIdx[known[j].Slug]
})
sort.Slice(unknown, func(i, j int) bool {
return unknown[i].Name < unknown[j].Name
})
return append(known, unknown...)
}
// renderLeagueSection renders header + lines for one league.
func renderLeagueSection(g leagueGroup) string {
lines := make([]string, len(g.Events))
for i, e := range g.Events {
lines[i] = formatEventLine(e)
}
return "<b>" + html.EscapeString(g.Name) + "</b>\n" + strings.Join(lines, "\n")
}
func renderDay(events []ScheduleEvent, day time.Time, emptyLine string) string {
header := "<b>LoL — " + html.EscapeString(formatIctDayLabel(day)) + "</b> (ICT)"
if len(events) == 0 {
return header + "\n" + emptyLine
}
groups := groupByLeague(events)
sections := make([]string, len(groups))
for i, g := range groups {
sections[i] = renderLeagueSection(g)
}
return header + "\n\n" + strings.Join(sections, "\n\n")
}
// RenderToday renders the today reply — grouped by league. day may be any
// instant on the target ICT day.
func RenderToday(events []ScheduleEvent, day time.Time) string {
return renderDay(events, day, "No major LoL matches today.")
}
func renderWeek(events []ScheduleEvent, from, to time.Time, emptyLine string) string {
fromLbl := html.EscapeString(formatIctDayLabel(from))
toLbl := html.EscapeString(formatIctDayLabel(to.Add(-time.Millisecond)))
header := "<b>LoL — " + fromLbl + " → " + toLbl + "</b> (ICT)"
if len(events) == 0 {
return header + "\n" + emptyLine
}
leagueBlocks := make([]string, 0, len(events))
for _, league := range groupByLeague(events) {
// Group this league's events by ICT day.
type dayBucket struct {
Label string
Lines []string
}
days := map[string]*dayBucket{}
for _, e := range league.Events {
t, err := time.Parse(time.RFC3339, e.StartTime)
if err != nil {
continue
}
key := ictDayKey(t)
d, ok := days[key]
if !ok {
d = &dayBucket{Label: formatIctDayLabel(t)}
days[key] = d
}
d.Lines = append(d.Lines, formatEventLine(e))
}
// Sort day keys chronologically (lexical works since YYYY-MM-DD).
keys := make([]string, 0, len(days))
for k := range days {
keys = append(keys, k)
}
sort.Strings(keys)
daySections := make([]string, len(keys))
for i, k := range keys {
d := days[k]
daySections[i] = "<i>" + html.EscapeString(d.Label) + "</i>\n" + strings.Join(d.Lines, "\n")
}
block := "<b>" + html.EscapeString(league.Name) + "</b>\n" + strings.Join(daySections, "\n")
leagueBlocks = append(leagueBlocks, block)
}
return header + "\n\n" + strings.Join(leagueBlocks, "\n\n")
}
// RenderWeek renders a week-range reply — grouped by league → day.
// `to` is exclusive (the start of the day after the range), so the label
// uses to-1.
func RenderWeek(events []ScheduleEvent, from, to time.Time) string {
return renderWeek(events, from, to, "No major LoL matches this week.")
}