feat(modules): port lolschedule (5 commands; daily-push cron deferred)

Phase 6e (final sub-phase of port-plan Phase 06). LoL esports match
schedule via lolesports.com's persisted API.

- internal/modules/lolschedule:
  - api_client.go: HTTP client with cache-first lookup (120s fresh
    window, 60-min stale fallback). Cache record shape matches JS so
    cross-runtime KV migration round-trips.
  - parse_date.go: ICT-anchored date parser. Accepts dd-mm-yyyy,
    dd/mm/yyyy, ddmmyyyy; trailing month/year may be omitted (default
    to current ICT month/year). Rejects impossible dates (Apr 31, Feb
    29 in non-leap, etc.).
  - format.go: Today (grouped by league) and Week (grouped by league
    -> day) renderers. Major-league filter (LCK/LPL/LEC/LCS/Worlds/
    MSI/etc.) keeps replies under Telegram's 4096-char limit. All
    user-influenced strings HTML-escaped.
  - subscribers.go: Idempotent add/remove/list keyed by chat id.
  - handlers.go: 5 commands (`/lolschedule [date]`,
    `/lolschedule_today`, `/lolschedule_week`,
    `/lolschedule_subscribe`, `/lolschedule_unsubscribe`).
- 22 tests across api-client (cache hit / miss / stale fallback /
  hard fail / show filter / non-JSON), parse-date (full and
  short formats, defaults, rejections, ICT anchor), format (event
  line states, league ordering, week grouping, HTML escape, major
  filter), subscribers (idempotent add/remove), handlers (HTML
  reply, error path, subscribe/unsubscribe round-trip).

Daily-push cron deferred to Phase 09 (Cloud Scheduler). Subscribers
are still collected so the push lights up the moment the cron infra
lands. Deps doesn't currently expose a *bot.Bot reference; that is
the prerequisite that Phase 09 will solve.

go test -race -count=1 ./... clean (19 packages); golangci-lint clean.
This commit is contained in:
2026-05-09 17:14:01 +07:00
parent 2e93d4aab4
commit d9a136cb40
14 changed files with 1639 additions and 5 deletions
+2
View File
@@ -18,6 +18,7 @@ import (
"github.com/tiennm99/miti99bot-go/internal/modules/loldleemoji"
"github.com/tiennm99/miti99bot-go/internal/modules/loldlequote"
"github.com/tiennm99/miti99bot-go/internal/modules/loldlesplash"
"github.com/tiennm99/miti99bot-go/internal/modules/lolschedule"
"github.com/tiennm99/miti99bot-go/internal/modules/misc"
"github.com/tiennm99/miti99bot-go/internal/modules/util"
"github.com/tiennm99/miti99bot-go/internal/modules/wordle"
@@ -39,6 +40,7 @@ func factories() map[string]modules.Factory {
"loldle-emoji": loldleemoji.New,
"loldle-quote": loldlequote.New,
"loldle-splash": loldlesplash.New,
"lolschedule": lolschedule.New,
}
}
+274
View File
@@ -0,0 +1,274 @@
// Package lolschedule ports the JS lolschedule module — LoL esports match
// schedule via lolesports.com's persisted API.
//
// Endpoint: https://esports-api.lolesports.com/persisted/gw/getSchedule
// Auth: x-api-key header (the public key embedded in lolesports.com's web
// client — no registration). If Riot ever rotates it, lift the new value
// from their public JS bundle.
//
// Cache strategy: KV-backed, 120s fresh window with 60-minute stale
// fallback. Same shape as the JS source so cross-runtime KV migration
// round-trips byte-for-byte.
package lolschedule
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/tiennm99/miti99bot-go/internal/log"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
const (
apiURL = "https://esports-api.lolesports.com/persisted/gw/getSchedule"
// apiKey is the public lolesports.com web client key (not a secret).
// gosec flags it as a hardcoded credential; the value is shipped in
// Riot's own public JS bundle and serves the live site too.
// #nosec G101
apiKey = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z"
userAgent = "miti99bot-go/0.1 (https://t.me/miti99bot)"
// CacheTTL: schedule data changes minute-by-minute during live events.
cacheTTL = 120 * time.Second
// staleMaxAge: how long to fall back to a cached payload when the
// upstream call fails outright.
staleMaxAge = 60 * 60 * time.Second
// httpTimeout: keep upstream calls bounded so a hung lolesports edge
// can't hold a Cloud Run instance.
httpTimeout = 8 * time.Second
)
// Team is one side of a match. JSON shape matches the lolesports response.
type Team struct {
Name string `json:"name,omitempty"`
Code string `json:"code,omitempty"`
Image string `json:"image,omitempty"`
Result *struct {
Outcome string `json:"outcome,omitempty"` // "win" or "loss"
GameWins int `json:"gameWins,omitempty"`
} `json:"result,omitempty"`
Record *struct {
Wins int `json:"wins,omitempty"`
Losses int `json:"losses,omitempty"`
} `json:"record,omitempty"`
}
// League holds the league-section-header info on each event.
type League struct {
Name string `json:"name,omitempty"`
Slug string `json:"slug,omitempty"`
Image string `json:"image,omitempty"`
}
// Strategy is the bestOf descriptor (Bo1, Bo3, Bo5).
type Strategy struct {
Type string `json:"type,omitempty"`
Count int `json:"count,omitempty"`
}
// Match is the inner match metadata.
type Match struct {
ID string `json:"id,omitempty"`
Teams []Team `json:"teams,omitempty"`
Strategy Strategy `json:"strategy,omitempty"`
}
// ScheduleEvent is one upcoming or past match. State is "unstarted",
// "inProgress", or "completed". Type is set to "show" for pre/post-show
// segments which we filter out.
type ScheduleEvent struct {
StartTime string `json:"startTime"`
State string `json:"state,omitempty"`
Type string `json:"type,omitempty"`
BlockName string `json:"blockName,omitempty"`
League League `json:"league,omitempty"`
Match Match `json:"match,omitempty"`
}
// schedulePage is the inner shape of an upstream response.
type schedulePage struct {
Data struct {
Schedule struct {
Events []ScheduleEvent `json:"events"`
Pages struct {
Newer string `json:"newer,omitempty"`
Older string `json:"older,omitempty"`
} `json:"pages,omitempty"`
} `json:"schedule"`
} `json:"data"`
}
// cacheRecord is the KV value: timestamp + events. Same shape as JS so KV
// export/import migration round-trips.
type cacheRecord struct {
Ts int64 `json:"ts"` // ms-since-epoch when fetched
Events []ScheduleEvent `json:"events"`
}
// Client is the lolesports API client. Default zero-value uses
// http.DefaultClient + http.DefaultTransport; tests inject a custom HTTP
// client (typically pointing at httptest.Server).
type Client struct {
HTTP *http.Client
URL string // override for tests; empty falls back to apiURL
}
// httpClient returns the client to use, or a sensible default.
func (c *Client) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: httpTimeout}
}
func (c *Client) baseURL() string {
if c.URL != "" {
return c.URL
}
return apiURL
}
// fetchSchedulePage retrieves one page of events. pageToken is the forward
// cursor from a previous call's `pages.newer`.
func (c *Client) fetchSchedulePage(ctx context.Context, pageToken string) ([]ScheduleEvent, string, error) {
u, err := url.Parse(c.baseURL())
if err != nil {
return nil, "", fmt.Errorf("lolschedule parse url: %w", err)
}
q := u.Query()
q.Set("hl", "en-US")
if pageToken != "" {
q.Set("pageToken", pageToken)
}
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, "", fmt.Errorf("lolschedule build request: %w", err)
}
req.Header.Set("x-api-key", apiKey)
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, "", fmt.Errorf("lolschedule do: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("lolschedule read: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Warn("lolschedule_fetch", "status", resp.StatusCode, "body", truncate(string(body), 500))
return nil, "", fmt.Errorf("lolschedule API HTTP %d", resp.StatusCode)
}
var page schedulePage
if err := json.Unmarshal(body, &page); err != nil {
return nil, "", fmt.Errorf("lolschedule decode: %w", err)
}
// Drop pre/post-show segments; they aren't matches.
out := make([]ScheduleEvent, 0, len(page.Data.Schedule.Events))
for _, e := range page.Data.Schedule.Events {
if e.Type == "show" {
continue
}
out = append(out, e)
}
return out, page.Data.Schedule.Pages.Newer, nil
}
// fetchEventsInRange paginates forward until the supplied window is covered
// or maxPages is reached. Default page returns ~20 events; week view
// usually needs 1 extra page.
func (c *Client) fetchEventsInRange(ctx context.Context, from, to time.Time, maxPages int) ([]ScheduleEvent, error) {
if maxPages <= 0 {
maxPages = 3
}
var collected []ScheduleEvent
pageToken := ""
for i := 0; i < maxPages; i++ {
events, newer, err := c.fetchSchedulePage(ctx, pageToken)
if err != nil {
return nil, err
}
collected = append(collected, events...)
// If the latest event in the page is already past our window end, stop.
if len(events) > 0 {
lastT, parseErr := time.Parse(time.RFC3339, events[len(events)-1].StartTime)
if parseErr == nil && !lastT.Before(to) {
break
}
}
if newer == "" {
break
}
pageToken = newer
}
out := make([]ScheduleEvent, 0, len(collected))
for _, e := range collected {
t, err := time.Parse(time.RFC3339, e.StartTime)
if err != nil {
continue
}
if !t.Before(from) && t.Before(to) {
out = append(out, e)
}
}
return out, nil
}
// cacheKey is `matches:<from-iso>:<to-iso>` — a stable key for a date range.
func cacheKey(from, to time.Time) string {
return "matches:" + from.UTC().Format(time.RFC3339) + ":" + to.UTC().Format(time.RFC3339)
}
// GetEventsCached is the cache-first lookup. Returns fresh cache within
// cacheTTL, else fetches upstream and writes back, else falls back to
// stale cache (within staleMaxAge), else propagates the error.
func (c *Client) GetEventsCached(ctx context.Context, kv storage.KVStore, from, to time.Time) ([]ScheduleEvent, error) {
key := cacheKey(from, to)
now := time.Now().UTC().UnixMilli()
var cached cacheRecord
cacheErr := kv.GetJSON(ctx, key, &cached)
hasCached := cacheErr == nil
if hasCached && now-cached.Ts < cacheTTL.Milliseconds() {
return cached.Events, nil
}
events, fetchErr := c.fetchEventsInRange(ctx, from, to, 3)
if fetchErr == nil {
rec := cacheRecord{Ts: now, Events: events}
if err := kv.PutJSON(ctx, key, rec); err != nil {
log.Warn("lolschedule_kv_put_fail", "err", err)
}
return events, nil
}
// Upstream failed — fall back to stale cache if recent enough.
if hasCached && cached.Events != nil && now-cached.Ts < staleMaxAge.Milliseconds() {
log.Warn("lolschedule_stale_fallback", "err", fetchErr)
return cached.Events, nil
}
return nil, fetchErr
}
// truncate clips a string to maxLen runes with "..." if cut. Keeps the log
// output bounded — lolesports occasionally returns multi-MB error pages.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// ErrEmptyResult is reserved for explicit "no events" scenarios where the
// fetch succeeded but returned zero matches. Currently unused outside tests
// but kept exported so callers can distinguish from network errors.
var ErrEmptyResult = errors.New("lolschedule: no events in range")
@@ -0,0 +1,187 @@
package lolschedule
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
// mkServer spins an httptest.Server returning the supplied JSON body for
// every page request. callCount counts upstream hits so cache tests can
// assert "1 fetch, then no more".
func mkServer(t *testing.T, body string) (*httptest.Server, *int32) {
t.Helper()
var count int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&count, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv, &count
}
const sampleBody = `{
"data": {
"schedule": {
"events": [
{
"startTime": "2026-05-09T05:00:00Z",
"state": "unstarted",
"league": {"slug": "lck", "name": "LCK"},
"match": {"teams": [{"code":"T1"},{"code":"GEN"}], "strategy":{"count":3}}
},
{
"startTime": "2026-05-09T08:00:00Z",
"state": "unstarted",
"type": "show",
"league": {"slug": "lck", "name": "LCK"},
"match": {"teams": [], "strategy":{}}
}
],
"pages": {"newer": null}
}
}
}`
func TestGetEventsCached_FirstHitFetchesUpstream(t *testing.T) {
srv, count := mkServer(t, sampleBody)
c := &Client{HTTP: srv.Client(), URL: srv.URL}
kv := storage.NewMemoryKVStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
events, err := c.GetEventsCached(context.Background(), kv, from, to)
if err != nil {
t.Fatalf("first fetch: %v", err)
}
if len(events) != 1 {
t.Errorf("events = %d, want 1 (show filtered out)", len(events))
}
if events[0].League.Slug != "lck" {
t.Errorf("event slug = %q, want lck", events[0].League.Slug)
}
if atomic.LoadInt32(count) != 1 {
t.Errorf("upstream calls = %d, want 1", *count)
}
}
func TestGetEventsCached_SecondHitUsesCache(t *testing.T) {
srv, count := mkServer(t, sampleBody)
c := &Client{HTTP: srv.Client(), URL: srv.URL}
kv := storage.NewMemoryKVStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
// First fetch primes the cache.
if _, err := c.GetEventsCached(context.Background(), kv, from, to); err != nil {
t.Fatal(err)
}
// Second fetch within TTL must NOT hit upstream.
if _, err := c.GetEventsCached(context.Background(), kv, from, to); err != nil {
t.Fatal(err)
}
if got := atomic.LoadInt32(count); got != 1 {
t.Errorf("upstream calls = %d, want 1 (cache should serve second call)", got)
}
}
func TestGetEventsCached_StaleFallback(t *testing.T) {
// Prime KV with a stale-but-still-fresh-enough cache record.
kv := storage.NewMemoryKVStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
staleEvents := []ScheduleEvent{
{StartTime: "2026-05-09T05:00:00Z", League: League{Slug: "lck", Name: "LCK"}},
}
// 10 minutes ago — past the 120s fresh window but well inside 60-min stale.
staleTs := time.Now().UTC().Add(-10 * time.Minute).UnixMilli()
if err := kv.PutJSON(context.Background(), cacheKey(from, to), cacheRecord{Ts: staleTs, Events: staleEvents}); err != nil {
t.Fatal(err)
}
// Upstream errors — server returns 500.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"down"}`))
}))
defer srv.Close()
c := &Client{HTTP: srv.Client(), URL: srv.URL}
got, err := c.GetEventsCached(context.Background(), kv, from, to)
if err != nil {
t.Fatalf("stale fallback should succeed: %v", err)
}
if len(got) != 1 || got[0].League.Slug != "lck" {
t.Errorf("stale fallback returned wrong events: %+v", got)
}
}
func TestGetEventsCached_HardFailureWhenNoCache(t *testing.T) {
kv := storage.NewMemoryKVStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := &Client{HTTP: srv.Client(), URL: srv.URL}
_, err := c.GetEventsCached(context.Background(), kv, from, to)
if err == nil {
t.Errorf("expected error when upstream fails AND no cache")
}
}
func TestFetchSchedulePage_DropsShowEvents(t *testing.T) {
srv, _ := mkServer(t, sampleBody)
c := &Client{HTTP: srv.Client(), URL: srv.URL}
events, _, err := c.fetchSchedulePage(context.Background(), "")
if err != nil {
t.Fatal(err)
}
for _, e := range events {
if e.Type == "show" {
t.Errorf("show event leaked: %+v", e)
}
}
}
func TestFetchSchedulePage_NonJSONErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("<html>not json</html>"))
}))
defer srv.Close()
c := &Client{HTTP: srv.Client(), URL: srv.URL}
_, _, err := c.fetchSchedulePage(context.Background(), "")
if err == nil || !strings.Contains(err.Error(), "decode") {
t.Errorf("non-JSON should produce decode error; got %v", err)
}
}
// truncate is internal but worth a smoke test — log payloads use it.
func TestTruncate(t *testing.T) {
if got := truncate("short", 10); got != "short" {
t.Errorf("truncate short = %q, want unchanged", got)
}
got := truncate("a long enough string", 5)
if got != "a lon..." {
t.Errorf("truncate = %q, want 'a lon...'", got)
}
}
// Smoke: ErrEmptyResult is exported and distinct from generic errors.
func TestErrEmptyResult_Identity(t *testing.T) {
if errors.Is(ErrEmptyResult, errors.New("other")) {
t.Error("ErrEmptyResult should not match arbitrary errors")
}
}
+262
View File
@@ -0,0 +1,262 @@
package lolschedule
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",
"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,
"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 = "<b>" + t1Label + "</b>"
}
right := t2Label
if t2.Result != nil && t2.Result.Outcome == "win" {
right = "<b>" + t2Label + "</b>"
}
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 "<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 {
header := "<b>LoL — " + html.EscapeString(formatIctDayLabel(day)) + "</b> (ICT)"
if len(events) == 0 {
return header + "\nNo matches today."
}
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")
}
// RenderWeek renders the next-7-days 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 {
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 matches this week."
}
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")
}
+194
View File
@@ -0,0 +1,194 @@
package lolschedule
import (
"strings"
"testing"
"time"
)
func mkEvent(state, slug, name, t1Code, t2Code, startISO string) ScheduleEvent {
return ScheduleEvent{
StartTime: startISO,
State: state,
League: League{Slug: slug, Name: name},
Match: Match{
Teams: []Team{{Code: t1Code}, {Code: t2Code}},
Strategy: Strategy{Type: "bestOf", Count: 3},
},
}
}
func TestFormatEventLine_Unstarted(t *testing.T) {
e := mkEvent("unstarted", "lck", "LCK", "T1", "GEN", "2026-05-09T05:00:00Z")
got := formatEventLine(e)
if !strings.Contains(got, "🕒") {
t.Errorf("missing clock emoji: %q", got)
}
if !strings.Contains(got, "T1 vs GEN") {
t.Errorf("missing team labels: %q", got)
}
if !strings.Contains(got, "Bo3") {
t.Errorf("missing Bo3: %q", got)
}
// 05:00 UTC == 12:00 ICT.
if !strings.Contains(got, "12:00") {
t.Errorf("ICT time wrong; got %q (expected 12:00)", got)
}
}
func TestFormatEventLine_Completed_BoldsWinner(t *testing.T) {
winResult := &struct {
Outcome string `json:"outcome,omitempty"`
GameWins int `json:"gameWins,omitempty"`
}{Outcome: "win", GameWins: 3}
loseResult := &struct {
Outcome string `json:"outcome,omitempty"`
GameWins int `json:"gameWins,omitempty"`
}{Outcome: "loss", GameWins: 1}
e := ScheduleEvent{
StartTime: "2026-05-09T05:00:00Z",
State: "completed",
League: League{Slug: "lck", Name: "LCK"},
Match: Match{
Teams: []Team{
{Code: "T1", Result: winResult},
{Code: "GEN", Result: loseResult},
},
Strategy: Strategy{Count: 5},
},
}
got := formatEventLine(e)
if !strings.Contains(got, "✅") {
t.Errorf("missing completed emoji: %q", got)
}
if !strings.Contains(got, "<b>T1</b>") {
t.Errorf("winner not bolded: %q", got)
}
if !strings.Contains(got, "31") {
t.Errorf("score missing: %q", got)
}
if strings.Contains(got, "<b>GEN</b>") {
t.Errorf("loser should not be bolded: %q", got)
}
}
func TestFormatEventLine_InProgress(t *testing.T) {
w := &struct {
Outcome string `json:"outcome,omitempty"`
GameWins int `json:"gameWins,omitempty"`
}{GameWins: 1}
e := ScheduleEvent{
StartTime: "2026-05-09T05:00:00Z",
State: "inProgress",
League: League{Slug: "lck"},
Match: Match{
Teams: []Team{{Code: "T1", Result: w}, {Code: "GEN", Result: w}},
Strategy: Strategy{Count: 5},
},
}
got := formatEventLine(e)
if !strings.Contains(got, "🔴 LIVE") {
t.Errorf("missing LIVE marker: %q", got)
}
if !strings.Contains(got, "11") {
t.Errorf("score missing: %q", got)
}
}
func TestRenderToday_GroupsByLeagueInOrder(t *testing.T) {
day := time.Date(2026, 5, 9, 0, 0, 0, 0, IctLocation)
events := []ScheduleEvent{
mkEvent("unstarted", "lcs", "LCS", "TL", "C9", "2026-05-09T18:00:00Z"),
mkEvent("unstarted", "lck", "LCK", "T1", "GEN", "2026-05-09T05:00:00Z"),
mkEvent("unstarted", "lpl", "LPL", "JDG", "BLG", "2026-05-09T08:00:00Z"),
}
got := RenderToday(events, day)
// LEAGUE_ORDER puts LCK before LPL before LCS.
idxLck := strings.Index(got, "<b>LCK</b>")
idxLpl := strings.Index(got, "<b>LPL</b>")
idxLcs := strings.Index(got, "<b>LCS</b>")
if idxLck < 0 || idxLpl < 0 || idxLcs < 0 {
t.Fatalf("missing league section; got:\n%s", got)
}
if idxLck >= idxLpl || idxLpl >= idxLcs {
t.Errorf("league order wrong: lck=%d lpl=%d lcs=%d\n%s", idxLck, idxLpl, idxLcs, got)
}
// Header in ICT.
if !strings.Contains(got, "LoL — Sat May 9</b> (ICT)") {
t.Errorf("header wrong: %q", got)
}
}
func TestRenderToday_EmptyShowsNoMatches(t *testing.T) {
day := time.Date(2026, 5, 9, 0, 0, 0, 0, IctLocation)
got := RenderToday(nil, day)
if !strings.Contains(got, "No matches today.") {
t.Errorf("empty render missing 'No matches today.': %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)
events := []ScheduleEvent{
mkEvent("unstarted", "lck", "LCK", "T1", "GEN", "2026-05-09T05:00:00Z"),
mkEvent("unstarted", "lck", "LCK", "DK", "KT", "2026-05-10T05:00:00Z"),
}
got := RenderWeek(events, from, to)
if !strings.Contains(got, "<b>LCK</b>") {
t.Errorf("missing LCK section: %q", got)
}
// Both days should appear under LCK.
if !strings.Contains(got, "Sat May 9") {
t.Errorf("missing Sat May 9: %q", got)
}
if !strings.Contains(got, "Sun May 10") {
t.Errorf("missing Sun May 10: %q", got)
}
}
func TestFilterMajor(t *testing.T) {
events := []ScheduleEvent{
{League: League{Slug: "lck"}},
{League: League{Slug: "lpl"}},
{League: League{Slug: "tcl"}}, // Turkish league — not in allowlist
{League: League{Slug: "lja"}}, // Japan academy — not in allowlist
{League: League{Slug: "msi"}},
}
got := FilterMajor(events)
if len(got) != 3 {
t.Errorf("filtered count = %d, want 3", len(got))
}
for _, e := range got {
if e.League.Slug == "tcl" || e.League.Slug == "lja" {
t.Errorf("non-major league leaked: %s", e.League.Slug)
}
}
}
func TestFormatEventLine_EscapesUserStrings(t *testing.T) {
e := ScheduleEvent{
StartTime: "2026-05-09T05:00:00Z",
State: "unstarted",
BlockName: "<script>",
League: League{Slug: "lck"},
Match: Match{
Teams: []Team{
{Name: "Tom & Jerry"},
{Name: `"Quotes"`},
},
Strategy: Strategy{Count: 1},
},
}
got := formatEventLine(e)
if strings.Contains(got, "<script>") {
t.Errorf("raw <script> leaked: %q", got)
}
if !strings.Contains(got, "&lt;script&gt;") {
t.Errorf("BlockName not escaped: %q", got)
}
if strings.Contains(got, "Tom & Jerry") {
// & should be escaped to &amp;
t.Errorf("ampersand not escaped: %q", got)
}
}
+120
View File
@@ -0,0 +1,120 @@
package lolschedule
import (
"context"
"time"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot-go/internal/log"
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
// state captures everything a lolschedule handler needs at runtime.
type state struct {
kv storage.KVStore
client *Client
// nowFn allows tests to inject a deterministic clock. Production code
// uses time.Now via the default zero-value.
nowFn func() time.Time
}
func (s *state) now() time.Time {
if s.nowFn != nil {
return s.nowFn()
}
return time.Now()
}
// handleSchedule is /lolschedule [date] — matches for one ICT day.
// Empty arg → today.
func (s *state) handleSchedule(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
arg := chathelper.ArgAfterCommand(msg.Text)
parsed := ParseScheduleDate(arg, s.now())
if !parsed.OK {
return chathelper.Reply(ctx, b, msg.Chat.ID, parsed.Error)
}
return s.replyForRange(ctx, b, msg.Chat.ID, parsed.Date, addDays(parsed.Date, 1), false)
}
// handleToday is /lolschedule_today — today's matches.
func (s *state) handleToday(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
from := ictDayStartOf(s.now())
return s.replyForRange(ctx, b, msg.Chat.ID, from, addDays(from, 1), false)
}
// handleWeek is /lolschedule_week — next 7 ICT days.
func (s *state) handleWeek(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
from := ictDayStartOf(s.now())
return s.replyForRange(ctx, b, msg.Chat.ID, from, addDays(from, 7), true)
}
// replyForRange fetches + filters + renders a date window. week=true uses
// RenderWeek; false uses RenderToday.
func (s *state) replyForRange(ctx context.Context, b *bot.Bot, chatID int64, from, to time.Time, week bool) error {
events, err := s.client.GetEventsCached(ctx, s.kv, from, to)
if err != nil {
log.Error("lolschedule_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."
}
return chathelper.Reply(ctx, b, chatID, hint)
}
filtered := FilterMajor(events)
var text string
if week {
text = RenderWeek(filtered, from, to)
} else {
text = RenderToday(filtered, from)
}
return chathelper.ReplyHTML(ctx, b, chatID, text)
}
// handleSubscribe is /lolschedule_subscribe — opt the chat into the daily
// digest (push wiring lands with Phase 09 Cloud Scheduler).
func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
added, err := addSubscriber(ctx, s.kv, msg.Chat.ID)
if err != nil {
return err
}
if added {
return chathelper.Reply(ctx, b, msg.Chat.ID,
"✅ Subscribed. You'll get today's LoL schedule at 08:00 ICT (push activates with the cron rollout).")
}
return chathelper.Reply(ctx, b, msg.Chat.ID, "Already subscribed.")
}
// handleUnsubscribe is /lolschedule_unsubscribe — opt out.
func (s *state) handleUnsubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
removed, err := removeSubscriber(ctx, s.kv, msg.Chat.ID)
if err != nil {
return err
}
if removed {
return chathelper.Reply(ctx, b, msg.Chat.ID, "Unsubscribed.")
}
return chathelper.Reply(ctx, b, msg.Chat.ID, "You weren't subscribed.")
}
@@ -0,0 +1,172 @@
package lolschedule
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/tiennm99/miti99bot-go/internal/modules"
"github.com/tiennm99/miti99bot-go/internal/storage"
"github.com/tiennm99/miti99bot-go/internal/testutil"
)
// installSchedule wires the lolschedule module to a recording bot, with a
// custom upstream HTTP server returning bodyJSON for every request. nowMs
// fixes the clock so date-based handlers are deterministic.
func installSchedule(t *testing.T, bodyJSON string, nowMs int64) (*testutil.RecordingBot, storage.KVStore) {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(bodyJSON))
}))
t.Cleanup(upstream.Close)
rb := testutil.NewRecordingBot(t)
provider := storage.NewMemoryProvider()
kv := provider.For("lolschedule")
s := &state{
kv: kv,
client: &Client{HTTP: upstream.Client(), URL: upstream.URL},
nowFn: func() time.Time { return time.UnixMilli(nowMs).UTC() },
}
mod := modules.Module{
Name: "lolschedule",
Commands: []modules.Command{
{Name: "lolschedule", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule},
{Name: "lolschedule_today", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleToday},
{Name: "lolschedule_week", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleWeek},
{Name: "lolschedule_subscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSubscribe},
{Name: "lolschedule_unsubscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleUnsubscribe},
},
}
reg := &modules.Registry{
Modules: []modules.Module{mod},
AllCommands: map[string]modules.Command{},
}
for _, c := range mod.Commands {
reg.AllCommands[c.Name] = c
}
modules.Install(rb.Bot, reg, modules.Auth{})
return rb, kv
}
// 2026-05-09 12:00 UTC = 19:00 ICT (still May 9 ICT day). Used as the fake
// "now" in handler tests.
const fakeNowMs int64 = 1778328000000 // 2026-05-09T12:00:00Z
const todayBody = `{
"data": {
"schedule": {
"events": [
{
"startTime": "2026-05-09T05:00:00Z",
"state": "unstarted",
"league": {"slug": "lck", "name": "LCK"},
"match": {"teams": [{"code":"T1"},{"code":"GEN"}], "strategy":{"count":3}}
}
],
"pages": {"newer": null}
}
}
}`
func TestHandleToday_RendersHTMLAndFiltersMajor(t *testing.T) {
rb, _ := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lolschedule_today"))
got := rb.LastSent()
if got.Method != "sendMessage" {
t.Errorf("method = %q, want sendMessage", got.Method)
}
if got.Form["parse_mode"] != "HTML" {
t.Errorf("parse_mode = %q, want HTML", got.Form["parse_mode"])
}
for _, want := range []string{"<b>LoL —", "(ICT)", "<b>LCK</b>", "T1 vs GEN"} {
if !strings.Contains(got.Text(), want) {
t.Errorf("missing %q in:\n%s", want, got.Text())
}
}
}
func TestHandleSchedule_BadDateInput(t *testing.T) {
rb, _ := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lolschedule notadate"))
got := rb.LastSent().Text()
if !strings.Contains(got, "Invalid date") {
t.Errorf("expected parse error reply; got %q", got)
}
}
func TestHandleWeek_RendersWeek(t *testing.T) {
rb, _ := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lolschedule_week"))
got := rb.LastSent().Text()
if !strings.Contains(got, "→") {
t.Errorf("week header missing arrow: %q", got)
}
}
func TestHandleSubscribe_AddsAndIsIdempotent(t *testing.T) {
rb, kv := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lolschedule_subscribe"))
if got := rb.LastSent().Text(); !strings.HasPrefix(got, "✅") {
t.Errorf("first subscribe should confirm; got %q", got)
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lolschedule_subscribe"))
if got := rb.LastSent().Text(); !strings.Contains(got, "Already subscribed") {
t.Errorf("duplicate subscribe should report Already; got %q", got)
}
ids, _ := listSubscribers(context.Background(), kv)
if len(ids) != 1 || ids[0] != 7 {
t.Errorf("subscribers = %v, want [7]", ids)
}
}
func TestHandleUnsubscribe(t *testing.T) {
rb, _ := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lolschedule_subscribe"))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lolschedule_unsubscribe"))
if got := rb.LastSent().Text(); got != "Unsubscribed." {
t.Errorf("unsubscribe reply = %q, want 'Unsubscribed.'", got)
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lolschedule_unsubscribe"))
if got := rb.LastSent().Text(); !strings.Contains(got, "weren't subscribed") {
t.Errorf("idempotent unsubscribe reply = %q", got)
}
}
func TestHandleSchedule_UpstreamFailureGivesFriendlyError(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer upstream.Close()
rb := testutil.NewRecordingBot(t)
provider := storage.NewMemoryProvider()
kv := provider.For("lolschedule")
s := &state{
kv: kv,
client: &Client{HTTP: upstream.Client(), URL: upstream.URL},
nowFn: func() time.Time { return time.UnixMilli(fakeNowMs).UTC() },
}
cmd := modules.Command{Name: "lolschedule_today", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleToday}
reg := &modules.Registry{
Modules: []modules.Module{{Name: "lolschedule", Commands: []modules.Command{cmd}}},
AllCommands: map[string]modules.Command{cmd.Name: cmd},
}
modules.Install(rb.Bot, reg, modules.Auth{})
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lolschedule_today"))
if got := rb.LastSent().Text(); !strings.Contains(got, "Could not fetch") {
t.Errorf("expected friendly fetch-error reply; got %q", got)
}
}
@@ -0,0 +1,50 @@
package lolschedule
import (
"github.com/tiennm99/miti99bot-go/internal/modules"
)
// New is the lolschedule module Factory. The 5 user-facing commands are
// wired here. The daily-push cron (08:00 ICT, fan-out to subscribers) is
// deferred to Phase 09 of the port plan: Cloud Scheduler will hit
// /cron/lolschedule_daily_push, and the cron handler needs a *bot.Bot
// reference which today's Deps doesn't expose. Subscribers are still
// collected by /lolschedule_subscribe so the push can light up the moment
// the cron infra lands.
func New(deps modules.Deps) modules.Module {
s := &state{kv: deps.KV, client: &Client{}}
return modules.Module{
Commands: []modules.Command{
{
Name: "lolschedule",
Visibility: modules.VisibilityPublic,
Description: "LoL matches for a date (dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy; default today)",
Handler: s.handleSchedule,
},
{
Name: "lolschedule_today",
Visibility: modules.VisibilityPublic,
Description: "Today's LoL esports matches (scores if played)",
Handler: s.handleToday,
},
{
Name: "lolschedule_week",
Visibility: modules.VisibilityPublic,
Description: "LoL esports matches for the next 7 days",
Handler: s.handleWeek,
},
{
Name: "lolschedule_subscribe",
Visibility: modules.VisibilityPublic,
Description: "Get the daily LoL schedule digest at 08:00 ICT",
Handler: s.handleSubscribe,
},
{
Name: "lolschedule_unsubscribe",
Visibility: modules.VisibilityPublic,
Description: "Stop receiving the daily LoL schedule digest",
Handler: s.handleUnsubscribe,
},
},
}
}
+127
View File
@@ -0,0 +1,127 @@
package lolschedule
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
// ictOffset is the ICT (UTC+7) offset. All day boundaries in this module
// are anchored on ICT.
const ictOffset = 7 * time.Hour
// formatHint is the user-facing usage line appended to parse errors.
const formatHint = "Use dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy."
// IctLocation is the fixed-offset UTC+7 timezone.
var IctLocation = time.FixedZone("ICT", int(ictOffset/time.Second))
// parseDateResult is the outcome of ParseScheduleDate. Date is the start of
// the requested ICT day, expressed as a UTC instant.
type parseDateResult struct {
OK bool
Date time.Time
Error string
}
var digitsOnly = regexp.MustCompile(`^\d+$`)
// ictDayStartOf returns the start of the ICT calendar day containing now,
// expressed as a UTC instant.
func ictDayStartOf(now time.Time) time.Time {
ict := now.In(IctLocation)
dayStart := time.Date(ict.Year(), ict.Month(), ict.Day(), 0, 0, 0, 0, IctLocation)
return dayStart.UTC()
}
// 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)
}
// splitParts breaks the trimmed input into [dd, mm?, yyyy?] string parts.
// Mirrors JS splitParts: dash- or slash-separated, or 1/2/4/8-digit unbroken.
func splitParts(trimmed string) ([]string, string) {
if strings.ContainsAny(trimmed, "-/") {
// Replace both delimiters with a single one, then split.
normalized := strings.ReplaceAll(trimmed, "/", "-")
parts := strings.Split(normalized, "-")
if len(parts) < 1 || len(parts) > 3 {
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
}
for _, p := range parts {
if p == "" || !digitsOnly.MatchString(p) {
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
}
}
return parts, ""
}
if !digitsOnly.MatchString(trimmed) {
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
}
switch len(trimmed) {
case 1, 2:
return []string{trimmed}, ""
case 4:
return []string{trimmed[:2], trimmed[2:]}, ""
case 8:
return []string{trimmed[:2], trimmed[2:4], trimmed[4:]}, ""
default:
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
}
}
// ParseScheduleDate parses a /lolschedule date argument. Empty input → today.
// Returns the start of the requested ICT day as a UTC instant.
func ParseScheduleDate(input string, now time.Time) parseDateResult {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return parseDateResult{OK: true, Date: ictDayStartOf(now)}
}
parts, errMsg := splitParts(trimmed)
if errMsg != "" {
return parseDateResult{Error: errMsg}
}
ictNow := now.In(IctLocation)
day, _ := strconv.Atoi(parts[0])
month := int(ictNow.Month())
year := ictNow.Year()
if len(parts) >= 2 {
month, _ = strconv.Atoi(parts[1])
}
if len(parts) >= 3 {
year, _ = strconv.Atoi(parts[2])
}
if day < 1 || day > 31 {
return parseDateResult{Error: fmt.Sprintf(`Invalid day %q — must be 131.`, parts[0])}
}
if month < 1 || month > 12 {
monthStr := ""
if len(parts) >= 2 {
monthStr = parts[1]
}
return parseDateResult{Error: fmt.Sprintf(`Invalid month %q — must be 112.`, monthStr)}
}
if year < 1970 || year > 2100 {
yearStr := ""
if len(parts) >= 3 {
yearStr = parts[2]
}
return parseDateResult{Error: fmt.Sprintf(`Invalid year %q.`, yearStr)}
}
// Build the ICT-midnight instant. time.Date normalises out-of-range days
// (e.g. April 31 → May 1) so we verify the round-trip below.
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, IctLocation)
if candidate.Year() != year || int(candidate.Month()) != month || candidate.Day() != day {
return parseDateResult{Error: fmt.Sprintf(`Invalid date — %d/%d/%d does not exist.`, day, month, year)}
}
return parseDateResult{OK: true, Date: candidate.UTC()}
}
@@ -0,0 +1,116 @@
package lolschedule
import (
"strings"
"testing"
"time"
)
// fixed reference now: 2026-05-09 12:00 UTC = 19:00 ICT (still May 9 ICT).
var refNow = time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC)
func TestParseScheduleDate_EmptyReturnsToday(t *testing.T) {
got := ParseScheduleDate("", refNow)
if !got.OK {
t.Fatalf("empty should be OK; err=%q", got.Error)
}
want := time.Date(2026, 5, 9, 0, 0, 0, 0, IctLocation).UTC()
if !got.Date.Equal(want) {
t.Errorf("empty → %v, want %v", got.Date, want)
}
}
func TestParseScheduleDate_FullFormats(t *testing.T) {
cases := []string{"15-06-2026", "15/06/2026", "15062026"}
want := time.Date(2026, 6, 15, 0, 0, 0, 0, IctLocation).UTC()
for _, in := range cases {
got := ParseScheduleDate(in, refNow)
if !got.OK {
t.Errorf("%q: not OK: %s", in, got.Error)
continue
}
if !got.Date.Equal(want) {
t.Errorf("%q → %v, want %v", in, got.Date, want)
}
}
}
func TestParseScheduleDate_DayOnly_DefaultsMonthYear(t *testing.T) {
got := ParseScheduleDate("15", refNow)
if !got.OK {
t.Fatalf("not OK: %s", got.Error)
}
want := time.Date(2026, 5, 15, 0, 0, 0, 0, IctLocation).UTC()
if !got.Date.Equal(want) {
t.Errorf("15 → %v, want %v", got.Date, want)
}
}
func TestParseScheduleDate_DayMonth_DefaultsYear(t *testing.T) {
got := ParseScheduleDate("15-06", refNow)
if !got.OK {
t.Fatalf("not OK: %s", got.Error)
}
want := time.Date(2026, 6, 15, 0, 0, 0, 0, IctLocation).UTC()
if !got.Date.Equal(want) {
t.Errorf("15-06 → %v, want %v", got.Date, want)
}
// 4-digit unbroken form: ddmm
got2 := ParseScheduleDate("1506", refNow)
if !got2.OK || !got2.Date.Equal(want) {
t.Errorf("1506 → %v ok=%v, want %v", got2.Date, got2.OK, want)
}
}
func TestParseScheduleDate_RejectsImpossibleDate(t *testing.T) {
got := ParseScheduleDate("31-04-2026", refNow) // April has only 30 days
if got.OK {
t.Errorf("31-04-2026 should be rejected; got %v", got.Date)
}
if !strings.Contains(got.Error, "does not exist") {
t.Errorf("error should mention non-existent date: %q", got.Error)
}
}
func TestParseScheduleDate_RejectsInvalidValues(t *testing.T) {
tests := []struct {
in string
hint string
}{
{"abc", "dd-mm-yyyy"},
{"32-01-2026", "must be 131"},
{"15-13-2026", "must be 112"},
{"15-06-1900", "Invalid year"},
{"-15", "Invalid date"}, // empty leading part
{"15--06", "Invalid date"},
}
for _, tt := range tests {
got := ParseScheduleDate(tt.in, refNow)
if got.OK {
t.Errorf("%q should be rejected", tt.in)
continue
}
if !strings.Contains(got.Error, tt.hint) {
t.Errorf("%q error %q missing hint %q", tt.in, got.Error, tt.hint)
}
}
}
func TestIctDayStartOf(t *testing.T) {
// 2026-05-09 19:00 ICT == 12:00 UTC. Start of ICT day = 2026-05-09 00:00 ICT
// = 2026-05-08 17:00 UTC.
got := ictDayStartOf(refNow)
want := time.Date(2026, 5, 8, 17, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Errorf("ictDayStartOf = %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)
want := time.Date(2026, 5, 12, 12, 30, 0, 0, time.UTC)
if !got.Equal(want) {
t.Errorf("addDays(3) = %v, want %v", got, want)
}
}
@@ -0,0 +1,73 @@
package lolschedule
import (
"context"
"errors"
"fmt"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
// subscribersKey is the KV slot holding the per-module subscriber list.
// Stored as a JSON array of int64 chat ids — same shape as JS so a
// cross-runtime KV migration round-trips byte-for-byte.
const subscribersKey = "subscribers"
// listSubscribers returns the current subscriber list, or an empty slice
// if none have ever subscribed.
func listSubscribers(ctx context.Context, kv storage.KVStore) ([]int64, error) {
var ids []int64
err := kv.GetJSON(ctx, subscribersKey, &ids)
switch {
case err == nil:
return ids, nil
case errors.Is(err, storage.ErrNotFound):
return nil, nil
default:
return nil, fmt.Errorf("lolschedule listSubscribers: %w", err)
}
}
// addSubscriber appends chatID if absent. Returns true on first-add, false
// when already subscribed (idempotent).
func addSubscriber(ctx context.Context, kv storage.KVStore, chatID int64) (bool, error) {
ids, err := listSubscribers(ctx, kv)
if err != nil {
return false, err
}
for _, id := range ids {
if id == chatID {
return false, nil
}
}
ids = append(ids, chatID)
if err := kv.PutJSON(ctx, subscribersKey, ids); err != nil {
return false, fmt.Errorf("lolschedule addSubscriber: %w", err)
}
return true, nil
}
// removeSubscriber drops chatID from the list. Returns true when removed,
// false when chatID wasn't present (idempotent).
func removeSubscriber(ctx context.Context, kv storage.KVStore, chatID int64) (bool, error) {
ids, err := listSubscribers(ctx, kv)
if err != nil {
return false, err
}
out := make([]int64, 0, len(ids))
removed := false
for _, id := range ids {
if id == chatID {
removed = true
continue
}
out = append(out, id)
}
if !removed {
return false, nil
}
if err := kv.PutJSON(ctx, subscribersKey, out); err != nil {
return false, fmt.Errorf("lolschedule removeSubscriber: %w", err)
}
return true, nil
}
@@ -0,0 +1,59 @@
package lolschedule
import (
"context"
"testing"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
func TestSubscribers_AddRemoveListIdempotent(t *testing.T) {
ctx := context.Background()
kv := storage.NewMemoryKVStore()
got, _ := listSubscribers(ctx, kv)
if len(got) != 0 {
t.Errorf("empty list = %v, want []", got)
}
// First add → true.
added, err := addSubscriber(ctx, kv, 42)
if err != nil || !added {
t.Fatalf("first add: added=%v err=%v", added, err)
}
// Idempotent re-add → false.
added, _ = addSubscriber(ctx, kv, 42)
if added {
t.Errorf("re-add should be no-op")
}
got, _ = listSubscribers(ctx, kv)
if len(got) != 1 || got[0] != 42 {
t.Errorf("after add(42): %v, want [42]", got)
}
// Add second.
if _, err := addSubscriber(ctx, kv, 7); err != nil {
t.Fatal(err)
}
got, _ = listSubscribers(ctx, kv)
if len(got) != 2 {
t.Errorf("after add(7): %v, want len 2", got)
}
// Remove → true.
removed, err := removeSubscriber(ctx, kv, 42)
if err != nil || !removed {
t.Fatalf("remove(42): removed=%v err=%v", removed, err)
}
// Idempotent re-remove → false.
removed, _ = removeSubscriber(ctx, kv, 42)
if removed {
t.Errorf("re-remove should be no-op")
}
got, _ = listSubscribers(ctx, kv)
if len(got) != 1 || got[0] != 7 {
t.Errorf("after remove(42): %v, want [7]", got)
}
}
@@ -71,16 +71,14 @@ This phase ships in five sub-cooks (one per module — each is large enough to r
- **6b:** loldle-quote — quote-pool variant, default 6 guesses. ✅ (consumes the shared `chathelper` + `champname` packages extracted in fix-all-review-findings Phase 03)
- **6c:** loldle-ability — DDragon ability-icon URL builder, sendPhoto reply, gameState gains a `slot` field so the same icon shows across guesses. ✅
- **6d:** loldle-splash — DDragon splash URL, sendPhoto reply, gameState locks `skinId` so the same splash shows across guesses. Default 4 guesses. ✅
- **6c (next):** loldle-ability — DDragon ability-icon URL builder, sendPhoto.
- **6d (next):** loldle-splash — DDragon splash URL builder, sendPhoto.
- **6e (next):** lolschedule — HTTP client to lolesports/leaguepedia API; no game state, different shape entirely.
- **6e:** lolschedule — HTTP client to lolesports.com persisted API (cache-first with 60-min stale fallback), ICT-anchored date parsing (dd-mm-yyyy / dd/mm/yyyy / ddmmyyyy), today/week renderers, subscriber list. 5 user commands shipped. Daily-push cron deferred to Phase 09 (Cloud Scheduler) since `Deps` doesn't currently expose a `*bot.Bot` reference. ✅
## Success Criteria
- [x] loldle-emoji responds to `/loldle_emoji`, `/loldle_emoji_giveup`, `/loldle_emoji_stats`, `/loldle_emoji_setmax`
- [x] loldle-quote responds to `/loldle_quote`, `/loldle_quote_giveup`, `/loldle_quote_stats`, `/loldle_quote_setmax`
- [x] loldle-ability responds to `/loldle_ability`, `/loldle_ability_giveup`, `/loldle_ability_stats`, `/loldle_ability_setmax`; sendPhoto path uses the DDragon icon URL directly
- [x] loldle-splash responds to `/loldle_splash`, `/loldle_splash_giveup`, `/loldle_splash_stats`, `/loldle_splash_setmax`; sendPhoto path uses the DDragon splash URL directly
- [ ] `/lolschedule today` matches JS behavior deferred to 6e
- [x] `/lolschedule [date]`, `/lolschedule_today`, `/lolschedule_week`, `/lolschedule_subscribe`, `/lolschedule_unsubscribe` match JS behavior; daily-push cron deferred to Phase 09
- [x] All variants share consistent guess-count limits matching JS (emoji 5, quote 6 — JS parity)
- [x] Ported tests pass for loldle-emoji + loldle-quote (lookup, state, render, JS-wire-format decode, handler integration)
+1 -1
View File
@@ -44,7 +44,7 @@ Full rewrite of miti99bot in Go for deployment on Cloud Run, swapping CF KV+D1+W
| 03 | [Module framework + storage interfaces](phase-03-module-framework.md) | done | 4h | Module/Command/Cron interfaces, registry, dispatcher |
| 04 | [Firestore KVStore + per-module prefixing](phase-04-firestore-kv.md) | done | 4h | `FirestoreKVStore`, emulator tests, KVProvider abstraction (Memory + Firestore) |
| 05 | [Port simple modules (util/misc/wordle/loldle)](phase-05-port-simple-modules.md) | done | 6h | 4 KV-only modules at JS parity; shared `internal/keylock` extracted |
| 06 | [Port loldle variants + lolschedule](phase-06-port-loldle-variants.md) | partial | 5h | 6a loldle-emoji done; quote/ability/splash/lolschedule pending |
| 06 | [Port loldle variants + lolschedule](phase-06-port-loldle-variants.md) | done | 5h | All five sub-modules ported (emoji, quote, ability, splash, lolschedule); lolschedule daily-push cron deferred to Phase 09 |
| 07 | [Gemini AI + port semantle/doantu/twentyq](phase-07-gemini-ai-modules.md) | pending | 6h | 3 AI modules with rate-limit handling |
| 08 | [Port trading + composite indexes](phase-08-port-trading.md) | pending | 6h | VN-stocks paper trading + daily price cron |
| 09 | [Cloud Scheduler cron wiring](phase-09-cloud-scheduler.md) | pending | 2h | 2 jobs → `/cron/{name}` with OIDC |