feat(lol): add tomorrow and next-week schedules

This commit is contained in:
2026-07-06 20:20:07 +07:00
parent a6b4085813
commit de31b5eb0a
9 changed files with 182 additions and 22 deletions
+1 -1
View File
@@ -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) |
+17 -9
View File
@@ -199,12 +199,10 @@ func renderLeagueSection(g leagueGroup) string {
return "<b>" + html.EscapeString(g.Name) + "</b>\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 := "<b>LoL — " + html.EscapeString(formatIctDayLabel(day)) + "</b> (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 := "<b>LoL — " + fromLbl + " → " + toLbl + "</b> (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.")
}
+19
View File
@@ -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"}},
+42 -11
View File
@@ -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)
}
+67
View File
@@ -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", "<b>LCK</b>", "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", "<b>LPL</b>", "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"))
+13 -1
View File
@@ -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 (MonSun, ICT)",
Handler: s.handleWeek,
},
{
Name: "lol_nextweek",
Visibility: modules.VisibilityPublic,
Description: "LoL esports matches for next week (MonSun, ICT)",
Handler: s.handleNextWeek,
},
{
Name: "lol_subscribe",
Visibility: modules.VisibilityPublic,
+6
View File
@@ -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)
+9
View File
@@ -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)
+8
View File
@@ -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"