diff --git a/README.md b/README.md index 614a3da..81a7fa2 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Atlas via long polling and an in-process cron scheduler. | `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/wheelofnamesbeta`, `/the_answer`, `/trongtruonghop` + `/tth` disclaimer | | `wordle` | Daily Wordle game | | `loldle` | League-of-Legends "guess the champion" | -| `lol` | Pro-match schedule + daily push | +| `lol` | Pro-match schedule (`/lol`, `/lol_tomorrow`, `/lol_this_week`, `/lol_nextweek`) + daily push | | `wc` | World Cup schedule + silent daily push | | `stock` | VN-stocks paper trading | | `gold` | Gold paper trading (opt-in; VNAppMob SJC buy/sell VND/luong) | diff --git a/internal/modules/lol/format.go b/internal/modules/lol/format.go index bbf80a3..c350329 100644 --- a/internal/modules/lol/format.go +++ b/internal/modules/lol/format.go @@ -199,12 +199,10 @@ func renderLeagueSection(g leagueGroup) string { return "" + html.EscapeString(g.Name) + "\n" + strings.Join(lines, "\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 { +func renderDay(events []ScheduleEvent, day time.Time, emptyLine string) string { header := "LoL — " + html.EscapeString(formatIctDayLabel(day)) + " (ICT)" if len(events) == 0 { - return header + "\nNo major LoL matches today." + return header + "\n" + emptyLine } groups := groupByLeague(events) sections := make([]string, len(groups)) @@ -214,15 +212,18 @@ func RenderToday(events []ScheduleEvent, day time.Time) string { return header + "\n\n" + strings.Join(sections, "\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 { +// 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 + "\nNo major LoL matches this week." + return header + "\n" + emptyLine } leagueBlocks := make([]string, 0, len(events)) @@ -262,3 +263,10 @@ func RenderWeek(events []ScheduleEvent, from, to time.Time) string { } 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.") +} diff --git a/internal/modules/lol/format_test.go b/internal/modules/lol/format_test.go index 386e019..f3926e8 100644 --- a/internal/modules/lol/format_test.go +++ b/internal/modules/lol/format_test.go @@ -120,6 +120,14 @@ func TestRenderToday_EmptyShowsNoMatches(t *testing.T) { } } +func TestRenderDay_CustomEmptyLine(t *testing.T) { + day := time.Date(2026, 5, 10, 0, 0, 0, 0, IctLocation) + got := renderDay(nil, day, "No major LoL matches tomorrow.") + if !strings.Contains(got, "Sun May 10") || !strings.Contains(got, "No major LoL matches tomorrow.") { + t.Errorf("custom day empty render wrong: %q", got) + } +} + func TestRenderWeek_GroupsByLeagueAndDay(t *testing.T) { from := time.Date(2026, 5, 9, 0, 0, 0, 0, IctLocation) to := from.AddDate(0, 0, 7) @@ -140,6 +148,17 @@ func TestRenderWeek_GroupsByLeagueAndDay(t *testing.T) { } } +func TestRenderWeek_CustomEmptyLine(t *testing.T) { + from := time.Date(2026, 5, 11, 0, 0, 0, 0, IctLocation) + to := from.AddDate(0, 0, 7) + got := renderWeek(nil, from, to, "No major LoL matches next week.") + for _, want := range []string{"Mon May 11", "Sun May 17", "No major LoL matches next week."} { + if !strings.Contains(got, want) { + t.Errorf("custom week empty render missing %q in %q", want, got) + } + } +} + func TestFilterMajor(t *testing.T) { events := []ScheduleEvent{ {League: League{Slug: "lck"}}, diff --git a/internal/modules/lol/handlers.go b/internal/modules/lol/handlers.go index ee92636..0c3df92 100644 --- a/internal/modules/lol/handlers.go +++ b/internal/modules/lol/handlers.go @@ -48,7 +48,23 @@ func (s *state) handleSchedule(ctx context.Context, b *bot.Bot, update *models.U return chathelper.Reply(ctx, b, msg, parsed.Error) } useFallback := strings.TrimSpace(arg) == "" - return s.replyForRange(ctx, b, msg, parsed.Date, addDays(parsed.Date, 1), false, useFallback) + emptyLine := "No major LoL matches on this day." + if useFallback { + emptyLine = "No major LoL matches today." + } + return s.replyForRange(ctx, b, msg, parsed.Date, addDays(parsed.Date, 1), false, useFallback, + emptyLine, "") +} + +// handleTomorrow is /lol_tomorrow — matches for tomorrow's ICT day. +func (s *state) handleTomorrow(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + from := addDays(ictDayStartOf(s.now()), 1) + return s.replyForRange(ctx, b, msg, from, addDays(from, 1), false, true, + "No major LoL matches tomorrow.", "Could not fetch tomorrow's matches. Try again later.") } // handleWeek is /lol_this_week — the current ICT calendar week @@ -59,12 +75,25 @@ func (s *state) handleWeek(ctx context.Context, b *bot.Bot, update *models.Updat return nil } from := ictWeekStartOf(s.now()) - return s.replyForRange(ctx, b, msg, from, addDays(from, 7), true, true) + return s.replyForRange(ctx, b, msg, from, addDays(from, 7), true, true, + "No major LoL matches this week.", "") } -// replyForRange fetches + filters + renders a date window. week=true uses -// RenderWeek; false uses RenderToday. -func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Message, from, to time.Time, week, useFallback bool) error { +// handleNextWeek is /lol_nextweek — the next ICT calendar week +// (Monday 00:00 ICT through the following Monday 00:00 ICT, exclusive). +func (s *state) handleNextWeek(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + from := ictNextWeekStartOf(s.now()) + return s.replyForRange(ctx, b, msg, from, addDays(from, 7), true, true, + "No major LoL matches next week.", "Could not fetch next week's matches. Try again later.") +} + +// replyForRange fetches, filters, and renders a date window. week=true groups +// by league and day; false groups by league only. +func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Message, from, to time.Time, week, useFallback bool, emptyLine, fetchErrorHint string) error { var ( events []ScheduleEvent err error @@ -76,18 +105,20 @@ func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Messa } if err != nil { log.Error("lol_fetch_fail", "err", err, "from", from, "to", to) - hint := "Could not fetch matches. Try again later." - if week { - hint = "Could not fetch this week's matches. Try again later." + if fetchErrorHint == "" { + fetchErrorHint = "Could not fetch matches. Try again later." + if week { + fetchErrorHint = "Could not fetch this week's matches. Try again later." + } } - return chathelper.Reply(ctx, b, msg, hint) + return chathelper.Reply(ctx, b, msg, fetchErrorHint) } filtered := FilterMajor(events) var text string if week { - text = RenderWeek(filtered, from, to) + text = renderWeek(filtered, from, to, emptyLine) } else { - text = RenderToday(filtered, from) + text = renderDay(filtered, from, emptyLine) } return chathelper.ReplyHTML(ctx, b, msg, text) } diff --git a/internal/modules/lol/handlers_test.go b/internal/modules/lol/handlers_test.go index 1c12ece..6632501 100644 --- a/internal/modules/lol/handlers_test.go +++ b/internal/modules/lol/handlers_test.go @@ -39,7 +39,9 @@ func installSchedule(t *testing.T, bodyJSON string, nowMs int64) (*testutil.Reco Name: "lol", Commands: []modules.Command{ {Name: "lol", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule}, + {Name: "lol_tomorrow", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleTomorrow}, {Name: "lol_this_week", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleWeek}, + {Name: "lol_nextweek", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleNextWeek}, {Name: "lol_subscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSubscribe}, {Name: "lol_unsubscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleUnsubscribe}, }, @@ -75,6 +77,28 @@ const todayBody = `{ } }` +const futureBody = `{ + "data": { + "schedule": { + "events": [ + { + "startTime": "2026-05-10T05:00:00Z", + "state": "unstarted", + "league": {"slug": "lck", "name": "LCK"}, + "match": {"teams": [{"code":"DK"},{"code":"KT"}], "strategy":{"count":3}} + }, + { + "startTime": "2026-05-12T08:00:00Z", + "state": "unstarted", + "league": {"slug": "lpl", "name": "LPL"}, + "match": {"teams": [{"code":"JDG"},{"code":"BLG"}], "strategy":{"count":5}} + } + ], + "pages": {"newer": null} + } + } +}` + func TestHandleSchedule_DefaultsToToday(t *testing.T) { rb, _ := installSchedule(t, todayBody, fakeNowMs) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol")) @@ -119,6 +143,49 @@ func TestHandleWeek_RendersWeek(t *testing.T) { } } +func TestHandleTomorrow_RendersTomorrow(t *testing.T) { + rb, _ := installSchedule(t, futureBody, fakeNowMs) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol_tomorrow")) + + got := rb.LastSent().Text() + for _, want := range []string{"Sun May 10", "LCK", "DK vs KT"} { + if !strings.Contains(got, want) { + t.Errorf("tomorrow reply missing %q in:\n%s", want, got) + } + } + if strings.Contains(got, "JDG vs BLG") { + t.Errorf("tomorrow reply included next-week match:\n%s", got) + } +} + +func TestHandleNextWeek_RendersNextWeek(t *testing.T) { + rb, _ := installSchedule(t, futureBody, fakeNowMs) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol_nextweek")) + + got := rb.LastSent().Text() + for _, want := range []string{"Mon May 11", "Sun May 17", "LPL", "JDG vs BLG"} { + if !strings.Contains(got, want) { + t.Errorf("next-week reply missing %q in:\n%s", want, got) + } + } + if strings.Contains(got, "DK vs KT") { + t.Errorf("next-week reply included tomorrow match:\n%s", got) + } +} + +func TestNewRegistersScheduleShortcuts(t *testing.T) { + mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection("lol")}) + got := map[string]bool{} + for _, cmd := range mod.Commands { + got[cmd.Name] = true + } + for _, want := range []string{"lol", "lol_tomorrow", "lol_this_week", "lol_nextweek"} { + if !got[want] { + t.Errorf("New() missing command %s", want) + } + } +} + func TestHandleSubscribe_AddsAndIsIdempotent(t *testing.T) { rb, subsStore := installSchedule(t, todayBody, fakeNowMs) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_subscribe")) diff --git a/internal/modules/lol/lol.go b/internal/modules/lol/lol.go index 72795d4..c3fc6bd 100644 --- a/internal/modules/lol/lol.go +++ b/internal/modules/lol/lol.go @@ -8,7 +8,7 @@ import ( // CollectionName is the MongoDB collection/module key used by the registry. const CollectionName = "lol" -// New is the lol module Factory. The 4 user-facing commands plus the +// New is the lol module Factory. The 6 user-facing commands plus the // daily-push cron (lol_daily_push at 08:00 ICT, fan-out to // subscribers) are wired here. The cron handler reads deps.Bot at invoke // time — main.go must wire BuildOptions.Bot for the cron to function; @@ -28,12 +28,24 @@ func New(deps modules.Deps) modules.Module { Description: "LoL matches for a date (dd, dd-mm, dd/mm, ddmm, or full date; default today)", Handler: s.handleSchedule, }, + { + Name: "lol_tomorrow", + Visibility: modules.VisibilityPublic, + Description: "LoL esports matches for tomorrow (ICT)", + Handler: s.handleTomorrow, + }, { Name: "lol_this_week", Visibility: modules.VisibilityPublic, Description: "LoL esports matches for this week (Mon–Sun, ICT)", Handler: s.handleWeek, }, + { + Name: "lol_nextweek", + Visibility: modules.VisibilityPublic, + Description: "LoL esports matches for next week (Mon–Sun, ICT)", + Handler: s.handleNextWeek, + }, { Name: "lol_subscribe", Visibility: modules.VisibilityPublic, diff --git a/internal/modules/lol/parse_date.go b/internal/modules/lol/parse_date.go index 8bc0bf6..556666a 100644 --- a/internal/modules/lol/parse_date.go +++ b/internal/modules/lol/parse_date.go @@ -47,6 +47,12 @@ func ictWeekStartOf(now time.Time) time.Time { return day.AddDate(0, 0, -daysFromMonday).UTC() } +// ictNextWeekStartOf returns the Monday 00:00 ICT after the current ICT +// calendar week, expressed as a UTC instant. +func ictNextWeekStartOf(now time.Time) time.Time { + return addDays(ictWeekStartOf(now), 7) +} + // addDays returns date + days, preserving time-of-day. func addDays(date time.Time, days int) time.Time { return date.Add(time.Duration(days) * 24 * time.Hour) diff --git a/internal/modules/lol/parse_date_test.go b/internal/modules/lol/parse_date_test.go index 77aca30..47f1925 100644 --- a/internal/modules/lol/parse_date_test.go +++ b/internal/modules/lol/parse_date_test.go @@ -128,6 +128,15 @@ func TestIctWeekStartOf(t *testing.T) { } } +func TestIctNextWeekStartOf(t *testing.T) { + // refNow is Sat 2026-05-09 19:00 ICT. Next ICT calendar week starts + // Mon 2026-05-11 00:00 ICT = 2026-05-10 17:00 UTC. + want := time.Date(2026, 5, 10, 17, 0, 0, 0, time.UTC) + if got := ictNextWeekStartOf(refNow); !got.Equal(want) { + t.Errorf("ictNextWeekStartOf(Sat) = %v, want %v", got, want) + } +} + func TestAddDays(t *testing.T) { base := time.Date(2026, 5, 9, 12, 30, 0, 0, time.UTC) got := addDays(base, 3) diff --git a/telegram-commands.json b/telegram-commands.json index d97b540..5724102 100644 --- a/telegram-commands.json +++ b/telegram-commands.json @@ -60,10 +60,18 @@ "command": "lol", "description": "LoL matches for a date; supports dd, dd-mm, dd/mm, ddmm" }, + { + "command": "lol_tomorrow", + "description": "LoL matches for tomorrow" + }, { "command": "lol_this_week", "description": "LoL matches for this week" }, + { + "command": "lol_nextweek", + "description": "LoL matches for next week" + }, { "command": "lol_subscribe", "description": "Get the daily LoL schedule digest"