Files
tiennm99bot/internal/modules/lol/format.go
T
tiennm99 cf620a257e docs: fix stale code comments and add missing package docs
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.
2026-09-30 14:19:01 +07:00

302 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",
}
// majorLeagueSlugs filters the upstream schedule down to the headline
// tournaments most viewers care about. Without this filter PandaScore returns
// ~270 matches/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,
}
// 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 shapes collapse to
// "": a nil Result, and the far more common Result with no Outcome that
// toScheduleEvent builds until PandaScore commits a winner.
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.")
}