mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-09 02:24:47 +00:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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, "3–1") {
|
||||
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, "1–1") {
|
||||
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, "<script>") {
|
||||
t.Errorf("BlockName not escaped: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "Tom & Jerry") {
|
||||
// & should be escaped to &
|
||||
t.Errorf("ampersand not escaped: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 1–31.`, 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 1–12.`, 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 1–31"},
|
||||
{"15-13-2026", "must be 1–12"},
|
||||
{"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)
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user