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 lolesports response 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" } // 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": var w1, w2 int if t1.Result != nil { w1 = t1.Result.GameWins } if t2.Result != nil { w2 = t2.Result.GameWins } left := t1Label if t1.Result != nil && t1.Result.Outcome == "win" { left = "" + t1Label + "" } right := t2Label if t2.Result != nil && t2.Result.Outcome == "win" { right = "" + t2Label + "" } return fmt.Sprintf("✅ %s %d–%d %s%s%s", left, w1, w2, right, bo, block) case "inProgress": var w1, w2 int if t1.Result != nil { w1 = t1.Result.GameWins } if t2.Result != nil { w2 = t2.Result.GameWins } return fmt.Sprintf("🔴 LIVE %s %d–%d %s%s%s", t1Label, w1, w2, 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 "" + html.EscapeString(g.Name) + "\n" + strings.Join(lines, "\n") } func renderDay(events []ScheduleEvent, day time.Time, emptyLine string) string { header := "LoL — " + html.EscapeString(formatIctDayLabel(day)) + " (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 := "LoL — " + fromLbl + " → " + toLbl + " (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] = "" + html.EscapeString(d.Label) + "\n" + strings.Join(d.Lines, "\n") } block := "" + html.EscapeString(league.Name) + "\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.") }