From 5971347a6b747cf3f8b56c067540c560acb45fca Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 29 Jun 2026 00:59:30 +0700 Subject: [PATCH] feat(wc): add world cup schedule module --- .env.example | 5 + README.md | 3 + cmd/server/main.go | 2 + cmd/server/main_test.go | 16 +- docs/deploy-coolify-selfhosted.md | 3 +- internal/modules/wc/client.go | 172 +++++++++++++ internal/modules/wc/client_test.go | 159 ++++++++++++ internal/modules/wc/cron.go | 174 +++++++++++++ internal/modules/wc/cron_test.go | 117 +++++++++ internal/modules/wc/format.go | 176 ++++++++++++++ internal/modules/wc/format_test.go | 82 +++++++ internal/modules/wc/handlers.go | 121 +++++++++ internal/modules/wc/handlers_test.go | 129 ++++++++++ internal/modules/wc/parse_date.go | 118 +++++++++ internal/modules/wc/parse_date_test.go | 36 +++ internal/modules/wc/subscribers.go | 98 ++++++++ internal/modules/wc/subscribers_test.go | 58 +++++ internal/modules/wc/terminal.go | 47 ++++ internal/modules/wc/types.go | 55 +++++ internal/modules/wc/wc.go | 51 ++++ internal/modules/wc/wc_test.go | 24 ++ .../plan.md | 50 ++++ .../research-260629-world-cup-schedule-api.md | 229 ++++++++++++++++++ telegram-commands.json | 20 ++ 24 files changed, 1936 insertions(+), 9 deletions(-) create mode 100644 internal/modules/wc/client.go create mode 100644 internal/modules/wc/client_test.go create mode 100644 internal/modules/wc/cron.go create mode 100644 internal/modules/wc/cron_test.go create mode 100644 internal/modules/wc/format.go create mode 100644 internal/modules/wc/format_test.go create mode 100644 internal/modules/wc/handlers.go create mode 100644 internal/modules/wc/handlers_test.go create mode 100644 internal/modules/wc/parse_date.go create mode 100644 internal/modules/wc/parse_date_test.go create mode 100644 internal/modules/wc/subscribers.go create mode 100644 internal/modules/wc/subscribers_test.go create mode 100644 internal/modules/wc/terminal.go create mode 100644 internal/modules/wc/types.go create mode 100644 internal/modules/wc/wc.go create mode 100644 internal/modules/wc/wc_test.go create mode 100644 plans/260629-0043-world-cup-schedule-module/plan.md create mode 100644 plans/reports/research-260629-world-cup-schedule-api.md diff --git a/.env.example b/.env.example index 1f6663d..fb16ddf 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,11 @@ ADMIN_IDS= # Only the twentyq module needs this. Leave blank to disable that command. GEMINI_API_KEY= +# The wc module uses football-data.org for World Cup schedule/live-score data. +# Leave blank to keep /wc commands loaded but replying with a not-configured +# message. +WC_FOOTBALL_DATA_TOKEN= + # SOURCE_COMMIT (commit SHA) is read at startup for the deploynotify owner DM. # Do NOT set it here. Coolify provides it at runtime. Keep "Include Source # Commit in Build" disabled so Docker layer cache survives across commits. diff --git a/README.md b/README.md index e74506b..8e754f3 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Atlas via long polling and an in-process cron scheduler. | `wordle` | Daily Wordle game | | `loldle` | League-of-Legends "guess the champion" | | `lolschedule` | Pro-match schedule + daily push | +| `wc` | World Cup schedule + daily push | | `twentyq` | 20-questions game (requires Gemini API key) | | `stock` | VN-stocks paper trading | | `gold` | Gold paper trading (opt-in; primary VNAppMob SJC buy/sell VND/luong, fallback spot XAU) | @@ -41,6 +42,7 @@ In-memory storage (no database required): ```sh TELEGRAM_BOT_TOKEN=… \ +WC_FOOTBALL_DATA_TOKEN=… \ MODULES= \ go run ./cmd/server ``` @@ -52,6 +54,7 @@ Persistent MongoDB locally (auto-selected when `MONGO_URL` is set): ```sh make mongo-local TELEGRAM_BOT_TOKEN=… \ +WC_FOOTBALL_DATA_TOKEN=… \ MONGO_URL=mongodb://127.0.0.1:27017 \ MONGO_DATABASE=miti99bot_dev \ go run ./cmd/server diff --git a/cmd/server/main.go b/cmd/server/main.go index 9c75250..31f1d73 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -27,6 +27,7 @@ import ( "github.com/tiennm99/miti99bot/internal/modules/stock" "github.com/tiennm99/miti99bot/internal/modules/twentyq" "github.com/tiennm99/miti99bot/internal/modules/util" + "github.com/tiennm99/miti99bot/internal/modules/wc" "github.com/tiennm99/miti99bot/internal/modules/wordle" "github.com/tiennm99/miti99bot/internal/server" "github.com/tiennm99/miti99bot/internal/storage" @@ -63,6 +64,7 @@ func factories() map[string]modules.Factory { "wordle": wordle.New, "loldle": loldle.New, "lolschedule": lolschedule.New, + "wc": wc.New, "coin": coin.New, "gold": gold.New, "twentyq": twentyq.New, diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index d10fc09..2bd0fb9 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -48,21 +48,21 @@ func TestComposeDoesNotOverrideSourceCommit(t *testing.T) { } } -func TestFactoriesIncludesGoldAndCoin(t *testing.T) { +func TestFactoriesIncludesExpectedModules(t *testing.T) { catalog := factories() - if catalog["gold"] == nil { - t.Fatal("factories missing gold") + for _, name := range []string{"gold", "coin", "wc"} { + if catalog[name] == nil { + t.Fatalf("factories missing %s", name) + } } - if catalog["coin"] == nil { - t.Fatal("factories missing coin") - } - reg, err := modules.Build([]string{"gold", "coin"}, catalog, storage.NewMemoryProvider(), modules.BuildOptions{}) + reg, err := modules.Build([]string{"gold", "coin", "wc"}, catalog, storage.NewMemoryProvider(), modules.BuildOptions{}) if err != nil { - t.Fatalf("Build gold: %v", err) + t.Fatalf("Build selected modules: %v", err) } for _, name := range []string{ "gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_stats", "coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_stats", + "wc", "wc_today", "wc_week", "wc_subscribe", "wc_unsubscribe", } { if _, ok := reg.AllCommands[name]; !ok { t.Fatalf("missing command %s", name) diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index 8e53c49..21a335c 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -34,6 +34,7 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in. | `OWNER_ID` | optional | owner-only commands (renamed from `BOT_OWNER_ID`) | | `ADMIN_IDS` | optional | CSV of admin ids (renamed from `ADMIN_USER_IDS`) | | `GEMINI_API_KEY` | optional | only the `twentyq` module needs it | +| `WC_FOOTBALL_DATA_TOKEN` | optional | football-data.org token for the `wc` module | **Leave UNSET on self-host:** `KV_PROVIDER`, `PORT`, `TELEGRAM_WEBHOOK_SECRET`, `GOLD_VNAPP_API_KEY`, and the `STOCK/COIN/GOLD @@ -64,7 +65,7 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in. > document — `{ _id: , ...payload fields, version, updatedAt }` with no > `value` envelope. Payload fields are hoisted to the document root so they > expand and are queryable in Compass. The two non-object values are wrapped in a -> named field: lolschedule subscribers under `subscribers` (array) and the daily +> named field: schedule subscribers under `subscribers` (array) and the daily > push date under `date`. Concurrency uses the `version` field (optimistic lock); > `updatedAt` is a BSON Date. diff --git a/internal/modules/wc/client.go b/internal/modules/wc/client.go new file mode 100644 index 0000000..f14a197 --- /dev/null +++ b/internal/modules/wc/client.go @@ -0,0 +1,172 @@ +package wc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/tiennm99/miti99bot/internal/log" +) + +const ( + apiURL = "https://api.football-data.org/v4/competitions/WC/matches" + userAgent = "miti99bot/0.1 (https://t.me/miti99bot)" + worldCupYear = "2026" + + staleMaxAge = 24 * time.Hour + httpTimeout = 8 * time.Second +) + +// ErrNotConfigured is returned when no football-data.org token is available. +var ErrNotConfigured = errors.New("wc: WC_FOOTBALL_DATA_TOKEN not set") + +// Client talks to football-data.org. Tests inject URL/HTTP/Token. +type Client struct { + HTTP *http.Client + URL string + Token string +} + +// NewClientFromEnv builds the production World Cup API client. +func NewClientFromEnv() *Client { + return &Client{Token: strings.TrimSpace(os.Getenv("WC_FOOTBALL_DATA_TOKEN"))} +} + +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 +} + +func (c *Client) token() string { + return strings.TrimSpace(c.Token) +} + +func (c *Client) fetchAllMatches(ctx context.Context) ([]Match, error) { + if c.token() == "" { + return nil, ErrNotConfigured + } + u, err := url.Parse(c.baseURL()) + if err != nil { + return nil, fmt.Errorf("wc parse url: %w", err) + } + q := u.Query() + q.Set("season", worldCupYear) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("wc build request: %w", err) + } + req.Header.Set("X-Auth-Token", c.token()) + 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("wc do: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("wc read: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Warn("wc_fetch", "status", resp.StatusCode, "body", truncateLog(string(body), 500)) + return nil, fmt.Errorf("wc API HTTP %d", resp.StatusCode) + } + + var out matchesResponse + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("wc decode: %w", err) + } + sortMatches(out.Matches) + return out.Matches, nil +} + +func cacheKey() string { + return "matches:" + worldCupYear +} + +// GetMatchesCached returns matches in [from, to). It is live-first: every call +// attempts football-data.org so TBD fixtures and live scores update quickly. +// The stored full-tournament payload is only a stale fallback when upstream +// fails. +func (c *Client) GetMatchesCached(ctx context.Context, cache CacheStore, from, to time.Time) ([]Match, error) { + now := time.Now().UTC().UnixMilli() + cached, _, cacheErr := cache.Get(ctx, cacheKey()) + hasCached := cacheErr == nil + + matches, fetchErr := c.fetchAllMatches(ctx) + if fetchErr == nil { + rec := cacheRecord{Ts: now, Matches: matches} + if err := cache.Put(ctx, cacheKey(), rec); err != nil { + log.Warn("wc_cache_put_fail", "err", err) + } + return filterMatches(matches, from, to), nil + } + + if hasCached && now-cached.Ts < staleMaxAge.Milliseconds() { + log.Warn("wc_stale_fallback", "err", fetchErr) + return filterMatches(cached.Matches, from, to), nil + } + return nil, fetchErr +} + +func filterMatches(matches []Match, from, to time.Time) []Match { + out := make([]Match, 0, len(matches)) + for _, m := range matches { + t, err := time.Parse(time.RFC3339, m.UTCDate) + if err != nil { + continue + } + if !t.Before(from) && t.Before(to) { + out = append(out, m) + } + } + sortMatches(out) + return out +} + +func sortMatches(matches []Match) { + sort.SliceStable(matches, func(i, j int) bool { + ti, errI := time.Parse(time.RFC3339, matches[i].UTCDate) + tj, errJ := time.Parse(time.RFC3339, matches[j].UTCDate) + if errI != nil || errJ != nil { + return matches[i].ID < matches[j].ID + } + if ti.Equal(tj) { + return matches[i].ID < matches[j].ID + } + return ti.Before(tj) + }) +} + +func truncateLog(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + cut := maxLen + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] + "..." +} diff --git a/internal/modules/wc/client_test.go b/internal/modules/wc/client_test.go new file mode 100644 index 0000000..a9737f5 --- /dev/null +++ b/internal/modules/wc/client_test.go @@ -0,0 +1,159 @@ +package wc + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/tiennm99/miti99bot/internal/storage" +) + +func newCacheStore() CacheStore { + return storage.Typed[cacheRecord](storage.NewMemoryProvider().Collection("wc")) +} + +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) + if got := r.Header.Get("X-Auth-Token"); got != "secret-token" { + t.Errorf("X-Auth-Token = %q, want secret-token", got) + } + if got := r.URL.Query().Get("season"); got != worldCupYear { + t.Errorf("season = %q, want %s", got, worldCupYear) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv, &count +} + +const sampleMatchesBody = `{ + "matches": [ + { + "id": 1, + "utcDate": "2026-06-12T13:00:00Z", + "status": "TIMED", + "stage": "GROUP_STAGE", + "group": "GROUP_A", + "venue": "Estadio Azteca", + "homeTeam": {"name": "Mexico", "shortName": "Mexico", "tla": "MEX"}, + "awayTeam": {"name": "South Africa", "shortName": "South Africa", "tla": "RSA"}, + "score": {"winner": null, "fullTime": {"home": null, "away": null}} + }, + { + "id": 2, + "utcDate": "2026-06-13T13:00:00Z", + "status": "TIMED", + "stage": "GROUP_STAGE", + "group": "GROUP_B", + "homeTeam": {"tla": "CAN"}, + "awayTeam": {"tla": "SUI"}, + "score": {"winner": null, "fullTime": {"home": null, "away": null}} + } + ] +}` + +func TestGetMatchesCached_FetchesAndFilters(t *testing.T) { + srv, count := mkServer(t, sampleMatchesBody) + c := &Client{HTTP: srv.Client(), URL: srv.URL, Token: "secret-token"} + cache := newCacheStore() + from := time.Date(2026, 6, 12, 0, 0, 0, 0, IctLocation).UTC() + to := addDays(from, 1) + + matches, err := c.GetMatchesCached(context.Background(), cache, from, to) + if err != nil { + t.Fatalf("fetch: %v", err) + } + if len(matches) != 1 || matches[0].HomeTeam.TLA != "MEX" { + t.Fatalf("matches = %+v, want only MEX match", matches) + } + if got := atomic.LoadInt32(count); got != 1 { + t.Fatalf("upstream calls = %d, want 1", got) + } +} + +func TestGetMatchesCached_AlwaysRefetchesWhenProviderIsAvailable(t *testing.T) { + var count int32 + firstBody := `{"matches":[{"id":1,"utcDate":"2026-06-12T13:00:00Z","status":"TIMED","homeTeam":{"tla":"MEX"},"awayTeam":{"tla":"RSA"}}]}` + secondBody := `{"matches":[{"id":1,"utcDate":"2026-06-12T13:00:00Z","status":"TIMED","homeTeam":{"tla":"BRA"},"awayTeam":{"tla":"RSA"}}]}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + n := atomic.AddInt32(&count, 1) + w.Header().Set("Content-Type", "application/json") + if n == 1 { + _, _ = w.Write([]byte(firstBody)) + return + } + _, _ = w.Write([]byte(secondBody)) + })) + defer srv.Close() + c := &Client{HTTP: srv.Client(), URL: srv.URL, Token: "secret-token"} + cache := newCacheStore() + + firstFrom := time.Date(2026, 6, 12, 0, 0, 0, 0, IctLocation).UTC() + if _, err := c.GetMatchesCached(context.Background(), cache, firstFrom, addDays(firstFrom, 1)); err != nil { + t.Fatal(err) + } + matches, err := c.GetMatchesCached(context.Background(), cache, firstFrom, addDays(firstFrom, 1)) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 || matches[0].HomeTeam.TLA != "BRA" { + t.Fatalf("second fetch = %+v, want refreshed BRA match", matches) + } + if got := atomic.LoadInt32(&count); got != 2 { + t.Fatalf("upstream calls = %d, want 2 live fetches", got) + } +} + +func TestGetMatchesCached_StaleFallback(t *testing.T) { + cache := newCacheStore() + from := time.Date(2026, 6, 12, 0, 0, 0, 0, IctLocation).UTC() + stale := []Match{{ID: 9, UTCDate: "2026-06-12T13:00:00Z", HomeTeam: Team{TLA: "MEX"}}} + rec := cacheRecord{Ts: time.Now().UTC().Add(-time.Hour).UnixMilli(), Matches: stale} + if err := cache.Put(context.Background(), cacheKey(), rec); err != nil { + t.Fatal(err) + } + 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, Token: "secret-token"} + + matches, err := c.GetMatchesCached(context.Background(), cache, from, addDays(from, 1)) + if err != nil { + t.Fatalf("stale fallback: %v", err) + } + if len(matches) != 1 || matches[0].ID != 9 { + t.Fatalf("matches = %+v, want stale match", matches) + } +} + +func TestGetMatchesCached_MissingToken(t *testing.T) { + c := &Client{Token: ""} + from := time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC) + _, err := c.GetMatchesCached(context.Background(), newCacheStore(), from, addDays(from, 1)) + if !errors.Is(err, ErrNotConfigured) { + t.Fatalf("err = %v, want ErrNotConfigured", err) + } +} + +func TestFetchAllMatches_NonJSONErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("not json")) + })) + defer srv.Close() + c := &Client{HTTP: srv.Client(), URL: srv.URL, Token: "secret-token"} + _, err := c.fetchAllMatches(context.Background()) + if err == nil || !strings.Contains(err.Error(), "decode") { + t.Fatalf("err = %v, want decode error", err) + } +} diff --git a/internal/modules/wc/cron.go b/internal/modules/wc/cron.go new file mode 100644 index 0000000..fb01732 --- /dev/null +++ b/internal/modules/wc/cron.go @@ -0,0 +1,174 @@ +package wc + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/storage" +) + +const dailyPushCronName = "wc_daily_push" + +// 01:00 UTC is 08:00 ICT. +const dailyPushSchedule = "0 1 * * *" + +const lastPushDateKey = "daily_push:last_date" + +const telegramRateLimitThreshold = 30 + +const telegramRateLimitDelay = 50 * time.Millisecond + +type messageSender interface { + SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) +} + +type lastPushDoc struct { + Date string `json:"date" bson:"date"` +} + +// PushDateStore is the typed store for last-push date documents. +type PushDateStore = storage.DocStore[lastPushDoc] + +func (s *state) dailyPushCron() modules.Cron { + return modules.Cron{ + Name: dailyPushCronName, + Schedule: dailyPushSchedule, + Handler: s.dailyPushHandler, + } +} + +func (s *state) dailyPushHandler(ctx context.Context, deps modules.Deps) error { + if deps.Bot == nil { + return errNilBot + } + return runDailyPush(ctx, s, deps.Bot) +} + +func claimDailyPush(ctx context.Context, store PushDateStore, today string) (bool, error) { + current, version, err := store.Get(ctx, lastPushDateKey) + switch { + case err == nil: + if current.Date == today { + return false, nil + } + case errors.Is(err, storage.ErrNotFound): + version = 0 + default: + return false, err + } + + if err := store.PutVersioned(ctx, lastPushDateKey, version, lastPushDoc{Date: today}); err != nil { + if errors.Is(err, storage.ErrConflict) { + return false, nil + } + return false, err + } + return true, nil +} + +func runDailyPush(ctx context.Context, s *state, sender messageSender) error { + subs, err := listSubscribers(ctx, s.subscribers) + if err != nil { + return fmt.Errorf("wc daily push: list subscribers: %w", err) + } + if len(subs) == 0 { + log.Info("wc daily push: no subscribers, skipping") + return nil + } + + from := ictDayStartOf(s.now()) + to := addDays(from, 1) + matches, err := s.client.GetMatchesCached(ctx, s.cache, from, to) + if err != nil { + return fmt.Errorf("wc daily push: fetch matches: %w", err) + } + text := RenderToday(matches, from) + + today := s.now().UTC().Format("2006-01-02") + won, err := claimDailyPush(ctx, s.pushDate, today) + if err != nil { + return fmt.Errorf("wc daily push: claim date: %w", err) + } + if !won { + log.Info("wc daily push: already pushed today, skipping", "date", today) + return nil + } + + throttle := len(subs) > telegramRateLimitThreshold + var sent, failed int + deadChats := map[int64]struct{}{} + var deadTopics []Subscriber + for i, sub := range subs { + if throttle && i > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(telegramRateLimitDelay): + } + } + if _, err := sender.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: sub.ChatID, + MessageThreadID: sub.ThreadID, + Text: text, + ParseMode: models.ParseModeHTML, + }); err != nil { + log.Warn("wc daily push send failed", "chat", sub.ChatID, "thread", sub.ThreadID, "err", err) + failed++ + switch classifyTerminal(err) { + case terminalChatWide: + deadChats[sub.ChatID] = struct{}{} + case terminalTopicOnly: + deadTopics = append(deadTopics, sub) + } + continue + } + sent++ + } + + pruned := pruneDeadSubscribers(ctx, s, deadChats, deadTopics) + log.Info("wc daily push complete", + "subscribers", len(subs), + "sent", sent, + "failed", failed, + "pruned", pruned, + "throttled", throttle) + return nil +} + +func pruneDeadSubscribers(ctx context.Context, s *state, chatWide map[int64]struct{}, topicOnly []Subscriber) int { + if len(chatWide) == 0 && len(topicOnly) == 0 { + return 0 + } + s.subscribersMu.Lock() + defer s.subscribersMu.Unlock() + removed := 0 + for chatID := range chatWide { + n, err := removeAllForChat(ctx, s.subscribers, chatID) + if err != nil { + log.Warn("wc prune dead chat failed", "chat", chatID, "err", err) + continue + } + removed += n + } + for _, sub := range topicOnly { + if _, ok := chatWide[sub.ChatID]; ok { + continue + } + ok, err := removeSubscriber(ctx, s.subscribers, sub.ChatID, sub.ThreadID) + if err != nil { + log.Warn("wc prune dead topic failed", "chat", sub.ChatID, "thread", sub.ThreadID, "err", err) + continue + } + if ok { + removed++ + } + } + return removed +} diff --git a/internal/modules/wc/cron_test.go b/internal/modules/wc/cron_test.go new file mode 100644 index 0000000..5a77c21 --- /dev/null +++ b/internal/modules/wc/cron_test.go @@ -0,0 +1,117 @@ +package wc + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/storage" +) + +type fakeSender struct { + mu sync.Mutex + calls []bot.SendMessageParams + terminalOn map[int64]bool + topicOnlyOn map[int64]bool + transientOn map[int64]bool +} + +func (f *fakeSender) SendMessage(_ context.Context, p *bot.SendMessageParams) (*models.Message, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, *p) + id, _ := p.ChatID.(int64) + if f.terminalOn[id] { + return nil, errors.New("Forbidden: bot was blocked by the user") + } + if f.topicOnlyOn[id] { + return nil, errors.New("Bad Request: have no rights to send a message") + } + if f.transientOn[id] { + return nil, errors.New("connection reset by peer") + } + return &models.Message{}, nil +} + +func newTestStores() (SubscriberStore, PushDateStore, CacheStore) { + col := storage.NewMemoryProvider().Collection("wc") + return storage.Typed[subscribersDoc](col), + storage.Typed[lastPushDoc](col), + storage.Typed[cacheRecord](col) +} + +func newTestState() *state { + subs, pd, cache := newTestStores() + return &state{ + subscribers: subs, + pushDate: pd, + cache: cache, + client: &Client{}, + nowFn: func() time.Time { return fakeNow }, + } +} + +func seedFreshCache(t *testing.T, cache CacheStore, matches []Match) { + t.Helper() + rec := cacheRecord{Ts: time.Now().UTC().UnixMilli(), Matches: matches} + if err := cache.Put(context.Background(), cacheKey(), rec); err != nil { + t.Fatalf("seed cache: %v", err) + } +} + +func TestRunDailyPush_SendsAndIsIdempotent(t *testing.T) { + s := newTestState() + seedFreshCache(t, s.cache, []Match{mkMatch("TIMED", "MEX", "RSA", "2026-06-12T13:00:00Z")}) + if _, err := addSubscriber(context.Background(), s.subscribers, 100, 7); err != nil { + t.Fatal(err) + } + sender := &fakeSender{} + for i := 0; i < 2; i++ { + if err := runDailyPush(context.Background(), s, sender); err != nil { + t.Fatalf("runDailyPush %d: %v", i, err) + } + } + if len(sender.calls) != 1 { + t.Fatalf("calls = %d, want 1", len(sender.calls)) + } + call := sender.calls[0] + if call.MessageThreadID != 7 || call.ParseMode != models.ParseModeHTML { + t.Fatalf("call = %+v, want thread 7 HTML", call) + } +} + +func TestRunDailyPush_PrunesDeadChat(t *testing.T) { + s := newTestState() + seedFreshCache(t, s.cache, nil) + for _, sub := range []Subscriber{{ChatID: 100}, {ChatID: 200}, {ChatID: 200, ThreadID: 9}} { + if _, err := addSubscriber(context.Background(), s.subscribers, sub.ChatID, sub.ThreadID); err != nil { + t.Fatal(err) + } + } + sender := &fakeSender{terminalOn: map[int64]bool{200: true}} + if err := runDailyPush(context.Background(), s, sender); err != nil { + t.Fatal(err) + } + remaining, _ := listSubscribers(context.Background(), s.subscribers) + if len(remaining) != 1 || remaining[0].ChatID != 100 { + t.Fatalf("remaining = %v, want only chat 100", remaining) + } +} + +func TestDailyPushCronRegistrationAndNilBot(t *testing.T) { + s := newTestState() + c := s.dailyPushCron() + if c.Name != dailyPushCronName || c.Schedule != dailyPushSchedule || c.Handler == nil { + t.Fatalf("cron = %+v", c) + } + err := s.dailyPushHandler(context.Background(), modules.Deps{Store: storage.NewMemoryProvider().Collection("wc")}) + if !errors.Is(err, errNilBot) { + t.Fatalf("err = %v, want errNilBot", err) + } +} diff --git a/internal/modules/wc/format.go b/internal/modules/wc/format.go new file mode 100644 index 0000000..0c3872c --- /dev/null +++ b/internal/modules/wc/format.go @@ -0,0 +1,176 @@ +package wc + +import ( + "fmt" + "html" + "sort" + "strings" + "time" +) + +func formatIctTime(t time.Time) string { + d := t.In(IctLocation) + return fmt.Sprintf("%02d:%02d", d.Hour(), d.Minute()) +} + +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()) +} + +func ictDayKey(t time.Time) string { + d := t.In(IctLocation) + return fmt.Sprintf("%04d-%02d-%02d", d.Year(), int(d.Month()), d.Day()) +} + +func teamLabel(t Team) string { + for _, s := range []string{t.TLA, t.ShortName, t.Name} { + s = strings.TrimSpace(s) + if s != "" && !strings.EqualFold(s, "TBD") { + return s + } + } + return "TBD" +} + +func stageLabel(stage, group string) string { + if strings.TrimSpace(group) != "" { + return titleToken(group) + } + switch stage { + case "GROUP_STAGE": + return "Group Stage" + case "LAST_16": + return "Last 16" + case "QUARTER_FINALS": + return "Quarter-finals" + case "SEMI_FINALS": + return "Semi-finals" + case "THIRD_PLACE": + return "Third Place" + case "FINAL": + return "Final" + default: + return titleToken(stage) + } +} + +func titleToken(s string) string { + s = strings.TrimSpace(strings.ReplaceAll(s, "_", " ")) + if s == "" { + return "" + } + parts := strings.Fields(strings.ToLower(s)) + for i, p := range parts { + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + return strings.Join(parts, " ") +} + +func scorePair(score Score) (int, int, bool) { + if score.FullTime.Home != nil && score.FullTime.Away != nil { + return *score.FullTime.Home, *score.FullTime.Away, true + } + if score.HalfTime.Home != nil && score.HalfTime.Away != nil { + return *score.HalfTime.Home, *score.HalfTime.Away, true + } + return 0, 0, false +} + +func formatMatchLine(m Match) string { + t, err := time.Parse(time.RFC3339, m.UTCDate) + if err != nil { + t = time.Time{} + } + home := html.EscapeString(teamLabel(m.HomeTeam)) + away := html.EscapeString(teamLabel(m.AwayTeam)) + meta := stageLabel(m.Stage, m.Group) + if m.Venue != "" { + if meta != "" { + meta += " - " + } + meta += m.Venue + } + if meta != "" { + meta = " (" + html.EscapeString(meta) + ")" + } + + homeGoals, awayGoals, hasScore := scorePair(m.Score) + switch m.Status { + case "IN_PLAY", "PAUSED": + if hasScore { + return fmt.Sprintf("LIVE %s %d-%d %s%s", home, homeGoals, awayGoals, away, meta) + } + return fmt.Sprintf("LIVE %s vs %s%s", home, away, meta) + case "FINISHED", "AWARDED": + if m.Score.Winner == "HOME_TEAM" { + home = "" + home + "" + } + if m.Score.Winner == "AWAY_TEAM" { + away = "" + away + "" + } + if hasScore { + return fmt.Sprintf("FT %s %d-%d %s%s", home, homeGoals, awayGoals, away, meta) + } + return fmt.Sprintf("FT %s vs %s%s", home, away, meta) + case "POSTPONED", "SUSPENDED", "CANCELLED": + return fmt.Sprintf("%s %s vs %s%s", strings.ReplaceAll(m.Status, "_", " "), home, away, meta) + default: + return fmt.Sprintf("%s %s vs %s%s", formatIctTime(t), home, away, meta) + } +} + +// RenderToday renders all World Cup matches on one ICT day. +func RenderToday(matches []Match, day time.Time) string { + header := "World Cup - " + html.EscapeString(formatIctDayLabel(day)) + " (ICT)" + if len(matches) == 0 { + return header + "\nNo matches today." + } + lines := make([]string, len(matches)) + for i, m := range matches { + lines[i] = formatMatchLine(m) + } + return header + "\n" + strings.Join(lines, "\n") +} + +// RenderWeek renders matches grouped by ICT day. +func RenderWeek(matches []Match, from, to time.Time) string { + fromLbl := html.EscapeString(formatIctDayLabel(from)) + toLbl := html.EscapeString(formatIctDayLabel(to.Add(-time.Millisecond))) + header := "World Cup - " + fromLbl + " -> " + toLbl + " (ICT)" + if len(matches) == 0 { + return header + "\nNo matches this week." + } + + type dayBucket struct { + Label string + Lines []string + } + days := map[string]*dayBucket{} + for _, m := range matches { + t, err := time.Parse(time.RFC3339, m.UTCDate) + 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, formatMatchLine(m)) + } + keys := make([]string, 0, len(days)) + for k := range days { + keys = append(keys, k) + } + sort.Strings(keys) + sections := make([]string, len(keys)) + for i, k := range keys { + d := days[k] + sections[i] = "" + html.EscapeString(d.Label) + "\n" + strings.Join(d.Lines, "\n") + } + return header + "\n\n" + strings.Join(sections, "\n\n") +} diff --git a/internal/modules/wc/format_test.go b/internal/modules/wc/format_test.go new file mode 100644 index 0000000..62787e0 --- /dev/null +++ b/internal/modules/wc/format_test.go @@ -0,0 +1,82 @@ +package wc + +import ( + "strings" + "testing" + "time" +) + +func intPtr(v int) *int { + return &v +} + +func mkMatch(status, home, away, startISO string) Match { + return Match{ + ID: 1, + UTCDate: startISO, + Status: status, + Stage: "GROUP_STAGE", + Group: "GROUP_A", + Venue: "Estadio Azteca", + HomeTeam: Team{ + TLA: home, + }, + AwayTeam: Team{ + TLA: away, + }, + } +} + +func TestFormatMatchLine_Scheduled(t *testing.T) { + got := formatMatchLine(mkMatch("TIMED", "MEX", "RSA", "2026-06-12T13:00:00Z")) + for _, want := range []string{"20:00", "MEX vs RSA", "Group A", "Estadio Azteca"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in %q", want, got) + } + } +} + +func TestFormatMatchLine_FinishedBoldsWinner(t *testing.T) { + m := mkMatch("FINISHED", "MEX", "RSA", "2026-06-12T13:00:00Z") + m.Score = Score{ + Winner: "HOME_TEAM", + FullTime: ScoreValue{Home: intPtr(2), Away: intPtr(0)}, + } + got := formatMatchLine(m) + for _, want := range []string{"FT", "MEX", "2-0", "RSA"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in %q", want, got) + } + } +} + +func TestFormatMatchLine_LiveScore(t *testing.T) { + m := mkMatch("IN_PLAY", "CAN", "SUI", "2026-06-13T13:00:00Z") + m.Score = Score{FullTime: ScoreValue{Home: intPtr(1), Away: intPtr(1)}} + got := formatMatchLine(m) + if !strings.Contains(got, "LIVE CAN 1-1 SUI") { + t.Fatalf("live line = %q", got) + } +} + +func TestRenderToday_Empty(t *testing.T) { + day := time.Date(2026, 6, 12, 0, 0, 0, 0, IctLocation) + got := RenderToday(nil, day) + if !strings.Contains(got, "World Cup") || !strings.Contains(got, "No matches today") { + t.Fatalf("empty render = %q", got) + } +} + +func TestRenderWeek_GroupsByDay(t *testing.T) { + from := time.Date(2026, 6, 12, 0, 0, 0, 0, IctLocation) + matches := []Match{ + mkMatch("TIMED", "MEX", "RSA", "2026-06-12T13:00:00Z"), + mkMatch("TIMED", "CAN", "SUI", "2026-06-13T13:00:00Z"), + } + got := RenderWeek(matches, from, addDays(from, 7)) + for _, want := range []string{"Fri Jun 12", "Sat Jun 13", "MEX vs RSA", "CAN vs SUI"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in:\n%s", want, got) + } + } +} diff --git a/internal/modules/wc/handlers.go b/internal/modules/wc/handlers.go new file mode 100644 index 0000000..d0d0bf4 --- /dev/null +++ b/internal/modules/wc/handlers.go @@ -0,0 +1,121 @@ +package wc + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" +) + +type state struct { + subscribers SubscriberStore + pushDate PushDateStore + cache CacheStore + client *Client + nowFn func() time.Time + + subscribersMu sync.Mutex +} + +func (s *state) now() time.Time { + if s.nowFn != nil { + return s.nowFn() + } + return time.Now() +} + +// handleSchedule is /wc [date], defaulting to today in ICT. +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, parsed.Error) + } + return s.replyForRange(ctx, b, msg, parsed.Date, addDays(parsed.Date, 1), false) +} + +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, from, addDays(from, 1), false) +} + +func (s *state) handleWeek(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + from := ictWeekStartOf(s.now()) + return s.replyForRange(ctx, b, msg, from, addDays(from, 7), true) +} + +func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Message, from, to time.Time, week bool) error { + matches, err := s.client.GetMatchesCached(ctx, s.cache, from, to) + if err != nil { + log.Error("wc_fetch_fail", "err", err, "from", from, "to", to) + if errors.Is(err, ErrNotConfigured) { + return chathelper.Reply(ctx, b, msg, "World Cup schedule is not configured (missing WC_FOOTBALL_DATA_TOKEN).") + } + hint := "Could not fetch World Cup matches. Try again later." + if week { + hint = "Could not fetch this week's World Cup matches. Try again later." + } + return chathelper.Reply(ctx, b, msg, hint) + } + var text string + if week { + text = RenderWeek(matches, from, to) + } else { + text = RenderToday(matches, from) + } + return chathelper.ReplyHTML(ctx, b, msg, text) +} + +func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + s.subscribersMu.Lock() + defer s.subscribersMu.Unlock() + added, err := addSubscriber(ctx, s.subscribers, msg.Chat.ID, msg.MessageThreadID) + if err != nil { + return err + } + if added { + return chathelper.Reply(ctx, b, msg, + "Subscribed. You'll get today's World Cup schedule at 08:00 ICT.\n"+ + "If you block the bot, you'll be auto-unsubscribed on the next push.") + } + return chathelper.Reply(ctx, b, msg, "Already subscribed.") +} + +func (s *state) handleUnsubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + s.subscribersMu.Lock() + defer s.subscribersMu.Unlock() + removed, err := removeSubscriber(ctx, s.subscribers, msg.Chat.ID, msg.MessageThreadID) + if err != nil { + return err + } + if removed { + return chathelper.Reply(ctx, b, msg, "Unsubscribed.") + } + return chathelper.Reply(ctx, b, msg, "You weren't subscribed.") +} diff --git a/internal/modules/wc/handlers_test.go b/internal/modules/wc/handlers_test.go new file mode 100644 index 0000000..a5d5245 --- /dev/null +++ b/internal/modules/wc/handlers_test.go @@ -0,0 +1,129 @@ +package wc + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/testutil" +) + +func installWC(t *testing.T, bodyJSON string, now time.Time) (*testutil.RecordingBot, SubscriberStore) { + 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) + col := storage.NewMemoryProvider().Collection("wc") + s := &state{ + subscribers: storage.Typed[subscribersDoc](col), + pushDate: storage.Typed[lastPushDoc](col), + cache: storage.Typed[cacheRecord](col), + client: &Client{HTTP: upstream.Client(), URL: upstream.URL, Token: "secret-token"}, + nowFn: func() time.Time { return now }, + } + mod := modules.Module{ + Name: "wc", + Commands: []modules.Command{ + {Name: "wc", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule}, + {Name: "wc_today", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleToday}, + {Name: "wc_week", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleWeek}, + {Name: "wc_subscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSubscribe}, + {Name: "wc_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, s.subscribers +} + +var fakeNow = time.Date(2026, 6, 12, 5, 0, 0, 0, time.UTC) + +func TestHandleToday_RendersHTML(t *testing.T) { + rb, _ := installWC(t, sampleMatchesBody, fakeNow) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wc_today")) + + got := rb.LastSent() + if got.Method != "sendMessage" { + t.Fatalf("method = %q, want sendMessage", got.Method) + } + if got.Form["parse_mode"] != "HTML" { + t.Fatalf("parse_mode = %q, want HTML", got.Form["parse_mode"]) + } + for _, want := range []string{"World Cup -", "MEX vs RSA", "Estadio Azteca"} { + if !strings.Contains(got.Text(), want) { + t.Fatalf("missing %q in:\n%s", want, got.Text()) + } + } +} + +func TestHandleSchedule_BadDateInput(t *testing.T) { + rb, _ := installWC(t, sampleMatchesBody, fakeNow) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wc notadate")) + if got := rb.LastSent().Text(); !strings.Contains(got, "Invalid date") { + t.Fatalf("reply = %q, want invalid date", got) + } +} + +func TestHandleSubscribe_AddsAndIsIdempotent(t *testing.T) { + rb, store := installWC(t, sampleMatchesBody, fakeNow) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wc_subscribe")) + if got := rb.LastSent().Text(); !strings.Contains(got, "Subscribed") { + t.Fatalf("first reply = %q, want subscribed", got) + } + rb.Reset() + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wc_subscribe")) + if got := rb.LastSent().Text(); !strings.Contains(got, "Already subscribed") { + t.Fatalf("second reply = %q, want already subscribed", got) + } + subs, _ := listSubscribers(context.Background(), store) + if len(subs) != 1 || subs[0] != (Subscriber{ChatID: 7}) { + t.Fatalf("subs = %v, want [{7 0}]", subs) + } +} + +func TestHandleSubscribe_ForumTopic(t *testing.T) { + rb, store := installWC(t, sampleMatchesBody, fakeNow) + upd := testutil.NewSupergroupMessage(555, 999, "/wc_subscribe") + upd.Message.MessageThreadID = 42 + rb.Bot.ProcessUpdate(context.Background(), upd) + + subs, _ := listSubscribers(context.Background(), store) + if len(subs) != 1 || subs[0] != (Subscriber{ChatID: 555, ThreadID: 42}) { + t.Fatalf("subs = %v, want topic subscription", subs) + } +} + +func TestHandleToday_MissingToken(t *testing.T) { + rb := testutil.NewRecordingBot(t) + col := storage.NewMemoryProvider().Collection("wc") + s := &state{ + subscribers: storage.Typed[subscribersDoc](col), + pushDate: storage.Typed[lastPushDoc](col), + cache: storage.Typed[cacheRecord](col), + client: &Client{}, + nowFn: func() time.Time { return fakeNow }, + } + cmd := modules.Command{Name: "wc_today", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleToday} + reg := &modules.Registry{ + Modules: []modules.Module{{Name: "wc", 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, "/wc_today")) + if got := rb.LastSent().Text(); !strings.Contains(got, "WC_FOOTBALL_DATA_TOKEN") { + t.Fatalf("reply = %q, want missing token hint", got) + } +} diff --git a/internal/modules/wc/parse_date.go b/internal/modules/wc/parse_date.go new file mode 100644 index 0000000..5e6861a --- /dev/null +++ b/internal/modules/wc/parse_date.go @@ -0,0 +1,118 @@ +package wc + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +const ictOffset = 7 * time.Hour + +const formatHint = "Use dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy." + +// IctLocation is the fixed-offset UTC+7 timezone for schedule display. +var IctLocation = time.FixedZone("ICT", int(ictOffset/time.Second)) + +type parseDateResult struct { + OK bool + Date time.Time + Error string +} + +var digitsOnly = regexp.MustCompile(`^\d+$`) + +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() +} + +func ictWeekStartOf(now time.Time) time.Time { + day := ictDayStartOf(now).In(IctLocation) + daysFromMonday := (int(day.Weekday()) + 6) % 7 + return day.AddDate(0, 0, -daysFromMonday).UTC() +} + +func addDays(date time.Time, days int) time.Time { + return date.Add(time.Duration(days) * 24 * time.Hour) +} + +func splitParts(trimmed string) ([]string, string) { + if strings.ContainsAny(trimmed, "-/") { + 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 /wc date argument. Empty input means today in ICT. +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)} + } + + 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()} +} diff --git a/internal/modules/wc/parse_date_test.go b/internal/modules/wc/parse_date_test.go new file mode 100644 index 0000000..3053a1b --- /dev/null +++ b/internal/modules/wc/parse_date_test.go @@ -0,0 +1,36 @@ +package wc + +import ( + "strings" + "testing" + "time" +) + +var refNow = time.Date(2026, 6, 12, 5, 0, 0, 0, time.UTC) + +func TestParseScheduleDate(t *testing.T) { + wantToday := time.Date(2026, 6, 12, 0, 0, 0, 0, IctLocation).UTC() + if got := ParseScheduleDate("", refNow); !got.OK || !got.Date.Equal(wantToday) { + t.Fatalf("empty = %+v, want %v", got, wantToday) + } + + wantFull := time.Date(2026, 7, 15, 0, 0, 0, 0, IctLocation).UTC() + for _, in := range []string{"15-07-2026", "15/07/2026", "15072026"} { + got := ParseScheduleDate(in, refNow) + if !got.OK || !got.Date.Equal(wantFull) { + t.Fatalf("%q = %+v, want %v", in, got, wantFull) + } + } + + if got := ParseScheduleDate("notadate", refNow); got.OK || !strings.Contains(got.Error, "Invalid date") { + t.Fatalf("invalid = %+v, want error", got) + } +} + +func TestIctWeekStartOf(t *testing.T) { + // refNow is Fri 2026-06-12 ICT. Monday is 2026-06-08 00:00 ICT. + want := time.Date(2026, 6, 8, 0, 0, 0, 0, IctLocation).UTC() + if got := ictWeekStartOf(refNow); !got.Equal(want) { + t.Fatalf("week start = %v, want %v", got, want) + } +} diff --git a/internal/modules/wc/subscribers.go b/internal/modules/wc/subscribers.go new file mode 100644 index 0000000..1784c1f --- /dev/null +++ b/internal/modules/wc/subscribers.go @@ -0,0 +1,98 @@ +package wc + +import ( + "context" + "errors" + "fmt" + + "github.com/tiennm99/miti99bot/internal/storage" +) + +const subscribersKey = "subscribers" + +// Subscriber is one chat/topic subscribed to the daily World Cup digest. +type Subscriber struct { + ChatID int64 `json:"chat_id" bson:"chat_id"` + ThreadID int `json:"thread_id,omitempty" bson:"thread_id,omitempty"` +} + +type subscribersDoc struct { + Subscribers []Subscriber `json:"subscribers" bson:"subscribers"` +} + +// SubscriberStore is the typed store for subscriber documents. +type SubscriberStore = storage.DocStore[subscribersDoc] + +func listSubscribers(ctx context.Context, store SubscriberStore) ([]Subscriber, error) { + doc, _, err := store.Get(ctx, subscribersKey) + switch { + case errors.Is(err, storage.ErrNotFound): + return nil, nil + case err != nil: + return nil, fmt.Errorf("wc listSubscribers: %w", err) + } + return doc.Subscribers, nil +} + +func addSubscriber(ctx context.Context, store SubscriberStore, chatID int64, threadID int) (bool, error) { + subs, err := listSubscribers(ctx, store) + if err != nil { + return false, err + } + for _, s := range subs { + if s.ChatID == chatID && s.ThreadID == threadID { + return false, nil + } + } + subs = append(subs, Subscriber{ChatID: chatID, ThreadID: threadID}) + if err := store.Put(ctx, subscribersKey, subscribersDoc{Subscribers: subs}); err != nil { + return false, fmt.Errorf("wc addSubscriber: %w", err) + } + return true, nil +} + +func removeSubscriber(ctx context.Context, store SubscriberStore, chatID int64, threadID int) (bool, error) { + subs, err := listSubscribers(ctx, store) + if err != nil { + return false, err + } + out := make([]Subscriber, 0, len(subs)) + removed := false + for _, s := range subs { + if s.ChatID == chatID && s.ThreadID == threadID { + removed = true + continue + } + out = append(out, s) + } + if !removed { + return false, nil + } + if err := store.Put(ctx, subscribersKey, subscribersDoc{Subscribers: out}); err != nil { + return false, fmt.Errorf("wc removeSubscriber: %w", err) + } + return true, nil +} + +func removeAllForChat(ctx context.Context, store SubscriberStore, chatID int64) (int, error) { + subs, err := listSubscribers(ctx, store) + if err != nil { + return 0, err + } + out := make([]Subscriber, 0, len(subs)) + removed := 0 + for _, s := range subs { + if s.ChatID == chatID { + removed++ + continue + } + out = append(out, s) + } + if removed == 0 { + return 0, nil + } + if err := store.Put(ctx, subscribersKey, subscribersDoc{Subscribers: out}); err != nil { + return 0, fmt.Errorf("wc removeAllForChat: %w", err) + } + return removed, nil +} diff --git a/internal/modules/wc/subscribers_test.go b/internal/modules/wc/subscribers_test.go new file mode 100644 index 0000000..d02b6b3 --- /dev/null +++ b/internal/modules/wc/subscribers_test.go @@ -0,0 +1,58 @@ +package wc + +import ( + "context" + "testing" + + "github.com/tiennm99/miti99bot/internal/storage" +) + +func newSubscriberStore() SubscriberStore { + return storage.Typed[subscribersDoc](storage.NewMemoryProvider().Collection("wc")) +} + +func TestSubscribers_AddRemoveAndTopics(t *testing.T) { + ctx := context.Background() + store := newSubscriberStore() + + for _, tid := range []int{0, 5, 9} { + added, err := addSubscriber(ctx, store, 100, tid) + if err != nil || !added { + t.Fatalf("add(100,%d): added=%v err=%v", tid, added, err) + } + } + if added, _ := addSubscriber(ctx, store, 100, 5); added { + t.Fatal("duplicate topic subscription should be no-op") + } + + if removed, _ := removeSubscriber(ctx, store, 100, 5); !removed { + t.Fatal("remove(100,5) should remove") + } + subs, _ := listSubscribers(ctx, store) + if len(subs) != 2 { + t.Fatalf("subs = %v, want 2", subs) + } + for _, sub := range subs { + if sub.ThreadID == 5 { + t.Fatalf("removed topic still present: %v", subs) + } + } +} + +func TestSubscribers_RemoveAllForChat(t *testing.T) { + ctx := context.Background() + store := newSubscriberStore() + for _, sub := range []Subscriber{{ChatID: 100}, {ChatID: 100, ThreadID: 9}, {ChatID: 200}} { + if _, err := addSubscriber(ctx, store, sub.ChatID, sub.ThreadID); err != nil { + t.Fatal(err) + } + } + n, err := removeAllForChat(ctx, store, 100) + if err != nil || n != 2 { + t.Fatalf("removeAllForChat = %d, %v; want 2, nil", n, err) + } + subs, _ := listSubscribers(ctx, store) + if len(subs) != 1 || subs[0].ChatID != 200 { + t.Fatalf("subs = %v, want only chat 200", subs) + } +} diff --git a/internal/modules/wc/terminal.go b/internal/modules/wc/terminal.go new file mode 100644 index 0000000..20e4b54 --- /dev/null +++ b/internal/modules/wc/terminal.go @@ -0,0 +1,47 @@ +package wc + +import ( + "errors" + "strings" +) + +type terminalKind int + +const ( + terminalNone terminalKind = iota + terminalChatWide + terminalTopicOnly +) + +var chatWideTerminalMarkers = []string{ + "bot was blocked by the user", + "user is deactivated", + "bot is not a member", + "chat not found", + "group chat was upgraded", + "chat was deleted", +} + +var topicOnlyTerminalMarkers = []string{ + "have no rights to send", +} + +func classifyTerminal(err error) terminalKind { + if err == nil { + return terminalNone + } + msg := err.Error() + for _, m := range chatWideTerminalMarkers { + if strings.Contains(msg, m) { + return terminalChatWide + } + } + for _, m := range topicOnlyTerminalMarkers { + if strings.Contains(msg, m) { + return terminalTopicOnly + } + } + return terminalNone +} + +var errNilBot = errors.New("wc daily push: deps.Bot is nil (BuildOptions.Bot not wired)") diff --git a/internal/modules/wc/types.go b/internal/modules/wc/types.go new file mode 100644 index 0000000..ff72da6 --- /dev/null +++ b/internal/modules/wc/types.go @@ -0,0 +1,55 @@ +package wc + +import "github.com/tiennm99/miti99bot/internal/storage" + +// Team is the football-data.org team object shape used by World Cup matches. +type Team struct { + ID int `json:"id,omitempty" bson:"id,omitempty"` + Name string `json:"name,omitempty" bson:"name,omitempty"` + ShortName string `json:"shortName,omitempty" bson:"shortName,omitempty"` + TLA string `json:"tla,omitempty" bson:"tla,omitempty"` + Crest string `json:"crest,omitempty" bson:"crest,omitempty"` +} + +// ScoreValue holds the home/away goals for one score phase. Pointers preserve +// "not available yet" separately from a real 0-0 score. +type ScoreValue struct { + Home *int `json:"home,omitempty" bson:"home,omitempty"` + Away *int `json:"away,omitempty" bson:"away,omitempty"` +} + +// Score is the subset of the football-data.org score payload that the bot +// needs for live/finished display. +type Score struct { + Winner string `json:"winner,omitempty" bson:"winner,omitempty"` + Duration string `json:"duration,omitempty" bson:"duration,omitempty"` + FullTime ScoreValue `json:"fullTime,omitempty" bson:"fullTime,omitempty"` + HalfTime ScoreValue `json:"halfTime,omitempty" bson:"halfTime,omitempty"` +} + +// Match is the normalized provider match persisted in the module cache. +type Match struct { + ID int `json:"id,omitempty" bson:"id,omitempty"` + UTCDate string `json:"utcDate,omitempty" bson:"utcDate,omitempty"` + Status string `json:"status,omitempty" bson:"status,omitempty"` + Matchday int `json:"matchday,omitempty" bson:"matchday,omitempty"` + Stage string `json:"stage,omitempty" bson:"stage,omitempty"` + Group string `json:"group,omitempty" bson:"group,omitempty"` + LastUpdated string `json:"lastUpdated,omitempty" bson:"lastUpdated,omitempty"` + HomeTeam Team `json:"homeTeam,omitempty" bson:"homeTeam,omitempty"` + AwayTeam Team `json:"awayTeam,omitempty" bson:"awayTeam,omitempty"` + Score Score `json:"score,omitempty" bson:"score,omitempty"` + Venue string `json:"venue,omitempty" bson:"venue,omitempty"` +} + +type matchesResponse struct { + Matches []Match `json:"matches"` +} + +type cacheRecord struct { + Ts int64 `json:"ts" bson:"ts"` + Matches []Match `json:"matches" bson:"matches"` +} + +// CacheStore is the typed store for World Cup schedule cache records. +type CacheStore = storage.DocStore[cacheRecord] diff --git a/internal/modules/wc/wc.go b/internal/modules/wc/wc.go new file mode 100644 index 0000000..b61fd9a --- /dev/null +++ b/internal/modules/wc/wc.go @@ -0,0 +1,51 @@ +package wc + +import ( + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/storage" +) + +// New is the World Cup schedule module factory. +func New(deps modules.Deps) modules.Module { + s := &state{ + subscribers: storage.Typed[subscribersDoc](deps.Store), + pushDate: storage.Typed[lastPushDoc](deps.Store), + cache: storage.Typed[cacheRecord](deps.Store), + client: NewClientFromEnv(), + } + return modules.Module{ + Commands: []modules.Command{ + { + Name: "wc", + Visibility: modules.VisibilityPublic, + Description: "World Cup matches for a date (dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy; default today)", + Handler: s.handleSchedule, + }, + { + Name: "wc_today", + Visibility: modules.VisibilityPublic, + Description: "Today's World Cup matches (scores if available)", + Handler: s.handleToday, + }, + { + Name: "wc_week", + Visibility: modules.VisibilityPublic, + Description: "World Cup matches for this week (Mon-Sun, ICT)", + Handler: s.handleWeek, + }, + { + Name: "wc_subscribe", + Visibility: modules.VisibilityPublic, + Description: "Get the daily World Cup schedule digest at 08:00 ICT", + Handler: s.handleSubscribe, + }, + { + Name: "wc_unsubscribe", + Visibility: modules.VisibilityPublic, + Description: "Stop receiving the daily World Cup schedule digest", + Handler: s.handleUnsubscribe, + }, + }, + Crons: []modules.Cron{s.dailyPushCron()}, + } +} diff --git a/internal/modules/wc/wc_test.go b/internal/modules/wc/wc_test.go new file mode 100644 index 0000000..94406ba --- /dev/null +++ b/internal/modules/wc/wc_test.go @@ -0,0 +1,24 @@ +package wc + +import ( + "testing" + + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/storage" +) + +func TestNewRegistersExpectedCommandsAndCron(t *testing.T) { + mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection("wc")}) + got := map[string]bool{} + for _, cmd := range mod.Commands { + got[cmd.Name] = true + } + for _, name := range []string{"wc", "wc_today", "wc_week", "wc_subscribe", "wc_unsubscribe"} { + if !got[name] { + t.Fatalf("missing command %s", name) + } + } + if len(mod.Crons) != 1 || mod.Crons[0].Name != dailyPushCronName { + t.Fatalf("crons = %+v, want %s", mod.Crons, dailyPushCronName) + } +} diff --git a/plans/260629-0043-world-cup-schedule-module/plan.md b/plans/260629-0043-world-cup-schedule-module/plan.md new file mode 100644 index 0000000..3823abd --- /dev/null +++ b/plans/260629-0043-world-cup-schedule-module/plan.md @@ -0,0 +1,50 @@ +--- +status: complete +created: 2026-06-29 +topic: world-cup-schedule-module +--- + +# World Cup Schedule Module Plan + +## Goal + +Add a `wc` module based on `lolschedule` for World Cup 2026 schedule lookup and +daily Telegram digest subscriptions. + +## Requirements + +- Commands: `/wc [date]`, `/wc_today`, `/wc_week`, `/wc_subscribe`, + `/wc_unsubscribe`. +- Daily push: 08:00 ICT via in-process cron, separate from `lolschedule`. +- Provider: football-data.org with `WC_FOOTBALL_DATA_TOKEN`. +- Live score: best-effort from provider `status` and `score`; commands fetch + live provider data and use cache only when provider fails. +- Storage: module-local typed `DocStore` records, flattened Mongo documents. + +## Files + +- Create `internal/modules/wc/*`. +- Update `cmd/server/main.go`. +- Update `telegram-commands.json`. +- Update `README.md`, `.env.example`, and `docs/deploy-coolify-selfhosted.md`. + +## Acceptance Criteria + +- `go test ./internal/modules/wc` passes. +- `go test ./cmd/server ./internal/modules` passes after registration updates. +- `go test ./...` passes. +- `go vet ./...` passes. +- Missing token returns a friendly command error instead of panicking. +- Live fetch happens on each command/cron call when upstream is available. +- Cached/stale matches work only when upstream fails. +- `/wc_subscribe` uses its own subscriber list and preserves forum topic IDs. + +## Out Of Scope + +- Paid API-Football fallback. +- Static no-key fallback. +- High-frequency live score polling. + +## Unresolved Questions + +None. diff --git a/plans/reports/research-260629-world-cup-schedule-api.md b/plans/reports/research-260629-world-cup-schedule-api.md new file mode 100644 index 0000000..1708a06 --- /dev/null +++ b/plans/reports/research-260629-world-cup-schedule-api.md @@ -0,0 +1,229 @@ +--- +type: research +date: 2026-06-29 +topic: world-cup-schedule-api-for-wc-module +status: complete +--- + +# Research Report: World Cup Schedule API For `wc` Module + +## Executive Summary + +Recommendation: use **football-data.org** as the primary provider for the first `wc` +module. It has FIFA World Cup coverage in the free tier, stable REST JSON, simple +match resources, and a practical free rate limit for a Telegram bot if we cache +like `lolschedule`. Use **API-Football / API-SPORTS** as the first paid/richer +fallback if football-data.org misses venue/live detail. + +Do not scrape FIFA. No stable official public FIFA developer API surfaced. Do +not make community/single-maintainer APIs the primary source for production +notifications. Static JSON datasets are useful as fallback/seed data only. + +## Research Methodology + +- Conducted: 2026-06-29 +- Sources consulted: 12 +- Local context: `internal/modules/lolschedule/*` +- Key search terms: `World Cup 2026 schedule API`, `football-data.org WC API`, + `API-Football World Cup 2026 fixtures`, `Sportmonks World Cup API`, + `TheSportsDB World Cup API`, `openfootball worldcup.json` +- Recency need: high. World Cup 2026 is active/current in this environment. + +## Existing `lolschedule` Pattern + +`lolschedule` has the right shape to copy: + +- client file owns upstream schema, HTTP, pagination, cache/stale fallback. +- handlers support day and week views. +- cron pushes a daily digest to subscribers. +- storage uses typed `DocStore`. +- renderer keeps upstream details out of handlers. + +For `wc`, the data model is simpler than LoL esports: + +- no pagination needed if provider returns full tournament/competition matches. +- date window filtering by ICT day/week still applies. +- render needs stage/group, teams, score/status, kickoff in ICT, venue. + +## Provider Comparison + +| Provider | Fit | Cost | Auth | Strengths | Risks | +|---|---:|---:|---|---|---| +| football-data.org | Best default | Free tier | `X-Auth-Token` | WC is listed in free tier; REST JSON; `WC` competition code; 10 req/min free | Register token; data richness lower than paid APIs | +| API-Football / API-SPORTS | Best rich fallback | Free 100/day; paid from $19/mo | `x-apisports-key` | Explicit World Cup 2026 guide; `fixtures?league=1&season=2026` returns all 104 matches with UTC date, venue, status | 100/day cap tight if low TTL; paid for comfortable usage | +| TheStatsAPI static JSON | Static fallback | Free static file; paid REST from $50/mo | none for static | Free JSON/CSV fixture file, all 104 matches, UTC kickoff, venues | Static file is not live status source; attribution likely required | +| OpenFootball `worldcup.json` | Seed/fallback | Free CC0 | none | Public domain, no key, includes 2026 fixtures/results JSON | Not live-updated; time format needs normalization | +| Sportmonks | Premium option | EUR 69/mo+ | token | Rich live scores/events/standings/bracket; strong docs | Overkill/cost for bot schedule digest | +| BALLDONTLIE FIFA | Niche rich option | trial/paid | `Authorization` | World Cup-specific endpoints for matches/standings/stats | Requires key; trial 5 req/min; betting-heavy | +| TheSportsDB | Not primary | Free/premium | free key/premium | Open sports DB, schedule endpoints | Crowd-sourced; free limits small; WC league mapping uncertain | +| `worldcup26.ir` GitHub API | Avoid primary | Free | none | Open-source, no auth, simple endpoints | Single-maintainer/SLA unknown; hosted domain dependency | + +## Primary Recommendation: football-data.org + +Use: + +```http +GET https://api.football-data.org/v4/competitions/WC/matches?season=2026 +X-Auth-Token: ${WC_FOOTBALL_DATA_TOKEN} +``` + +Why: + +- Free-tier coverage page lists **Worldcup** under free competitions. +- API docs list competition code `WC` for World-Cup. +- Match resource includes scheduled date, status, teams, score shape. +- Free registered rate is 10 req/min, enough with cache. +- It is cleaner than one-off World Cup APIs and cheaper than Sportmonks. + +Cache policy: + +- Cache whole tournament match list. +- TTL 10-15 min while tournament active. +- Stale fallback 6-24h for schedule display if provider fails. +- Do not poll per chat. One provider call should serve all `/wc*` commands. + +Expected env: + +```sh +WC_PROVIDER=football-data +WC_FOOTBALL_DATA_TOKEN=... +``` + +## Fallback / Upgrade Recommendation: API-Football + +Use if football-data.org lacks needed detail: + +```http +GET https://v3.football.api-sports.io/fixtures?league=1&season=2026 +x-apisports-key: ${WC_API_FOOTBALL_KEY} +``` + +Why: + +- Their World Cup 2026 guide explicitly says this returns all 104 fixtures with + fixture id, UTC date/time, venue, and status. +- Free plan is 100 requests/day and all endpoints are listed as available. +- Paid $19/mo plan gives 7,500/day; easy upgrade if the bot is popular. + +Use as provider interface implementation, not hard-coded into handlers. + +## Static Fallbacks + +Use only when no API key is configured: + +1. TheStatsAPI static JSON: `https://www.thestatsapi.com/world-cup/data/fixtures.json` +2. OpenFootball raw JSON: `https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json` + +Behavior: + +- Show schedule only. +- No live scores guarantee. +- Mark output as static/stale if needed. + +This keeps `/wc` usable for local dev and avoids failing hard when env missing. + +## Proposed `wc` Module Shape + +Commands: + +- `/wc [date]` — World Cup matches for one ICT day. +- `/wc_today` — today in ICT. +- `/wc_week` — current ICT week. +- `/wc_subscribe` — daily digest at 08:00 ICT. +- `/wc_unsubscribe`. + +Internal files, mirroring `lolschedule`: + +```text +internal/modules/wc/ + wc.go + handlers.go + api_client.go + provider_football_data.go + provider_api_football.go # optional fallback/phase 2 + provider_static.go # optional no-key fallback + format.go + parse_date.go + subscribers.go + cron.go + *_test.go +``` + +Normalized model: + +```go +type Match struct { + ID string + StartTime time.Time // UTC + Stage string + Group string + Home Team + Away Team + Status string + Score Score + Venue string + Source string +} +``` + +Renderer should output: + +```text +🏆 World Cup — Mon 29/06 +20:00 Group A Mexico 2-0 South Africa FT +23:00 Group B Canada vs Switzerland BMO Field +``` + +## Security Considerations + +- Treat provider tokens as secrets. Add to `.env.example`, never log. +- Use env names scoped to `wc`, e.g. `WC_FOOTBALL_DATA_TOKEN`. +- HTTP timeout 8s, same as `lolschedule`. +- Log status code and truncated body only; never log auth headers. +- Cache to Mongo to reduce provider quota and avoid chat-driven burst. + +## Performance / Reliability + +- Single full-list fetch is cheaper than date-by-date provider queries. +- Cache by provider + season (`wc:football-data:2026`). +- Filter in memory by ICT day/week. +- Stale-while-error is mandatory during tournament: a stale schedule is better + than no schedule. +- During live matches, do not promise second-level live scores unless provider + and quota support it. For Telegram, 10-15 min freshness is acceptable. + +## Decision + +Implement provider interface with **football-data.org first**. Keep API-Football +as the documented fallback/upgrade. Do not start with Sportmonks or custom +single-maintainer APIs. + +## References + +- football-data.org coverage: https://www.football-data.org/coverage +- football-data.org API docs: https://www.football-data.org/documentation/api +- football-data.org pricing: https://www.football-data.org/pricing +- API-Football World Cup 2026 guide: https://www.api-football.com/news/post/fifa-world-cup-2026-guide-to-using-data-with-api-sports +- API-Football pricing: https://www.api-football.com/pricing +- API-Sports football pricing/details: https://api-sports.io/sports/football +- Sportmonks World Cup API: https://www.sportmonks.com/football-api/world-cup-api/ +- Sportmonks World Cup guide: https://www.sportmonks.com/blogs/world-cup-2026-api-guide-coverage-endpoints-data-types/ +- TheStatsAPI static data: https://www.thestatsapi.com/world-cup/data +- OpenFootball worldcup.json: https://github.com/openfootball/worldcup.json +- TheSportsDB docs: https://www.thesportsdb.com/docs_api_guide +- BALLDONTLIE FIFA API: https://fifa.balldontlie.io/ + +## Next Steps + +1. Add `WC_FOOTBALL_DATA_TOKEN` to `.env.example` and Coolify env. +2. Plan `wc` module as a copy-shaped sibling of `lolschedule`. +3. Implement provider interface + football-data.org client. +4. Add no-key static fallback only if user wants local zero-config behavior. +5. Register commands in `telegram-commands.json` and README. + +## Unresolved Questions + +- Do we want live score freshness or only schedule/daily digest? +- Is adding a free football-data.org token acceptable for production env? +- Should `/wc_subscribe` be separate from `/lolschedule_subscribe`, or should + future schedule modules share a generic subscription store? diff --git a/telegram-commands.json b/telegram-commands.json index 8145141..af4671e 100644 --- a/telegram-commands.json +++ b/telegram-commands.json @@ -60,6 +60,26 @@ "command": "lolschedule_unsubscribe", "description": "Stop the daily LoL schedule digest" }, + { + "command": "wc", + "description": "World Cup matches for a date" + }, + { + "command": "wc_today", + "description": "Today's World Cup matches" + }, + { + "command": "wc_week", + "description": "World Cup matches for this week" + }, + { + "command": "wc_subscribe", + "description": "Get the daily World Cup schedule digest" + }, + { + "command": "wc_unsubscribe", + "description": "Stop the daily World Cup schedule digest" + }, { "command": "twentyq", "description": "20 questions; ask yes/no questions"