mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-07-23 18:20:45 +00:00
feat(lol): add tomorrow and next-week schedules
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user