diff --git a/.env.example b/.env.example
index a8378b4..867436f 100644
--- a/.env.example
+++ b/.env.example
@@ -19,11 +19,6 @@ OWNER_ID=
# Comma-separated admin Telegram user ids (renamed from ADMIN_USER_IDS).
ADMIN_IDS=
-# 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 181b085..49244ba 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,6 @@ Atlas via long polling and an in-process cron scheduler.
| `wordle` | Daily Wordle game |
| `loldle` | League-of-Legends "guess the champion" |
| `lol` | Pro-match schedule (`/lol`, `/lol_tomorrow`, `/lol_this_week`, `/lol_next_week`) + daily push |
-| `wc` | World Cup schedule + silent daily push |
| `stock` | VN-stocks paper trading |
| `gold` | Gold paper trading (opt-in; VNAppMob SJC buy/sell VND/luong) |
| `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) |
@@ -41,7 +40,6 @@ In-memory storage (no database required):
```sh
TELEGRAM_BOT_TOKEN=… \
-WC_FOOTBALL_DATA_TOKEN=… \
MODULES= \
go run ./cmd/server
```
@@ -53,7 +51,6 @@ 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 a51d123..cbe1f3b 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -25,7 +25,6 @@ import (
"github.com/tiennm99/miti99bot/internal/modules/stats"
"github.com/tiennm99/miti99bot/internal/modules/stock"
"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"
@@ -62,7 +61,6 @@ func factories() map[string]modules.Factory {
"wordle": wordle.New,
"loldle": loldle.New,
lol.CollectionName: lol.New,
- "wc": wc.New,
"coin": coin.New,
"gold": gold.New,
"stock": stock.New,
diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go
index f6bb63c..68bdaea 100644
--- a/cmd/server/main_test.go
+++ b/cmd/server/main_test.go
@@ -50,19 +50,18 @@ func TestComposeDoesNotOverrideSourceCommit(t *testing.T) {
func TestFactoriesIncludesExpectedModules(t *testing.T) {
catalog := factories()
- for _, name := range []string{"gold", "coin", "wc"} {
+ for _, name := range []string{"gold", "coin"} {
if catalog[name] == nil {
t.Fatalf("factories missing %s", name)
}
}
- reg, err := modules.Build([]string{"gold", "coin", "wc"}, catalog, storage.NewMemoryProvider(), modules.BuildOptions{})
+ reg, err := modules.Build([]string{"gold", "coin"}, catalog, storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build selected modules: %v", err)
}
for _, name := range []string{
"gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_portfolio",
"coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_portfolio",
- "wc", "wc_this_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 9b532bd..2fbf07a 100644
--- a/docs/deploy-coolify-selfhosted.md
+++ b/docs/deploy-coolify-selfhosted.md
@@ -33,7 +33,6 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in.
| `MODULES` | optional | CSV; empty = all modules |
| `OWNER_ID` | optional | owner-only commands (renamed from `BOT_OWNER_ID`) |
| `ADMIN_IDS` | optional | CSV of admin ids (renamed from `ADMIN_USER_IDS`) |
-| `WC_FOOTBALL_DATA_TOKEN` | optional | football-data.org token for the `wc` module |
| `WHEELOFNAMES_API_URL` | optional | full `/api/gif` endpoint for remote `/wheelofnames` GIF rendering |
| `WHEELOFNAMES_API_TOKEN` | optional | bearer token matching the wheelofnames service `API_TOKEN` |
diff --git a/internal/modules/wc/client.go b/internal/modules/wc/client.go
deleted file mode 100644
index f14a197..0000000
--- a/internal/modules/wc/client.go
+++ /dev/null
@@ -1,172 +0,0 @@
-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
deleted file mode 100644
index a9737f5..0000000
--- a/internal/modules/wc/client_test.go
+++ /dev/null
@@ -1,159 +0,0 @@
-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
deleted file mode 100644
index f3aea30..0000000
--- a/internal/modules/wc/cron.go
+++ /dev/null
@@ -1,175 +0,0 @@
-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"
-
-// 17:00 UTC is 00:00 UTC+7 (ICT).
-const dailyPushSchedule = "0 17 * * *"
-
-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, pushDay string) (bool, error) {
- current, version, err := store.Get(ctx, lastPushDateKey)
- switch {
- case err == nil:
- if current.Date == pushDay {
- return false, nil
- }
- case errors.Is(err, storage.ErrNotFound):
- version = 0
- default:
- return false, err
- }
-
- if err := store.PutVersioned(ctx, lastPushDateKey, version, lastPushDoc{Date: pushDay}); 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)
-
- pushDay := ictDayKey(from)
- won, err := claimDailyPush(ctx, s.pushDate, pushDay)
- 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", pushDay)
- 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,
- DisableNotification: true,
- }); 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
deleted file mode 100644
index 6729de5..0000000
--- a/internal/modules/wc/cron_test.go
+++ /dev/null
@@ -1,146 +0,0 @@
-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 || !call.DisableNotification {
- t.Fatalf("call = %+v, want thread 7 HTML silent notification", call)
- }
-}
-
-func TestRunDailyPush_ClaimsICTDayAtMidnight(t *testing.T) {
- s := newTestState()
- s.nowFn = func() time.Time {
- return time.Date(2026, 6, 12, 17, 0, 0, 0, time.UTC) // 2026-06-13 00:00 ICT
- }
- seedFreshCache(t, s.cache, []Match{mkMatch("TIMED", "MEX", "RSA", "2026-06-12T18:00:00Z")})
- if _, err := addSubscriber(context.Background(), s.subscribers, 100, 0); err != nil {
- t.Fatal(err)
- }
- if err := s.pushDate.Put(context.Background(), lastPushDateKey, lastPushDoc{Date: "2026-06-12"}); err != nil {
- t.Fatal(err)
- }
-
- sender := &fakeSender{}
- if err := runDailyPush(context.Background(), s, sender); err != nil {
- t.Fatal(err)
- }
- if len(sender.calls) != 1 {
- t.Fatalf("calls = %d, want 1 midnight ICT push", len(sender.calls))
- }
- doc, _, err := s.pushDate.Get(context.Background(), lastPushDateKey)
- if err != nil {
- t.Fatal(err)
- }
- if doc.Date != "2026-06-13" {
- t.Fatalf("last push date = %q, want ICT day 2026-06-13", doc.Date)
- }
-}
-
-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 != "0 17 * * *" || 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
deleted file mode 100644
index 0c3872c..0000000
--- a/internal/modules/wc/format.go
+++ /dev/null
@@ -1,176 +0,0 @@
-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
deleted file mode 100644
index 62787e0..0000000
--- a/internal/modules/wc/format_test.go
+++ /dev/null
@@ -1,82 +0,0 @@
-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
deleted file mode 100644
index ecbca68..0000000
--- a/internal/modules/wc/handlers.go
+++ /dev/null
@@ -1,128 +0,0 @@
-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) 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 subscriptionScope(msg *models.Message) string {
- if msg != nil && msg.MessageThreadID != 0 {
- return "this topic"
- }
- return "this chat"
-}
-
-func subscriptionScopeSentenceSubject(msg *models.Message) string {
- if msg != nil && msg.MessageThreadID != 0 {
- return "This topic"
- }
- return "This chat"
-}
-
-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
- }
- scope := subscriptionScope(msg)
- if added {
- return chathelper.Reply(ctx, b, msg,
- "Subscribed "+scope+" to the daily World Cup schedule at 00:00 UTC+7.\n"+
- "If you block the bot, you'll be auto-unsubscribed on the next push.")
- }
- return chathelper.Reply(ctx, b, msg, "Already subscribed in "+scope+".")
-}
-
-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
- }
- scope := subscriptionScope(msg)
- if removed {
- return chathelper.Reply(ctx, b, msg, "Unsubscribed "+scope+".")
- }
- return chathelper.Reply(ctx, b, msg, subscriptionScopeSentenceSubject(msg)+" wasn't subscribed.")
-}
diff --git a/internal/modules/wc/handlers_test.go b/internal/modules/wc/handlers_test.go
deleted file mode 100644
index f4aff13..0000000
--- a/internal/modules/wc/handlers_test.go
+++ /dev/null
@@ -1,143 +0,0 @@
-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_this_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 TestHandleSchedule_DefaultsToToday(t *testing.T) {
- rb, _ := installWC(t, sampleMatchesBody, fakeNow)
- rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wc"))
-
- 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 TestHandleWeek_RendersThisWeek(t *testing.T) {
- rb, _ := installWC(t, sampleMatchesBody, fakeNow)
- rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wc_this_week"))
-
- got := rb.LastSent().Text()
- for _, want := range []string{"Mon Jun 8", "Sun Jun 14", "MEX vs RSA", "CAN vs SUI"} {
- if !strings.Contains(got, want) {
- t.Fatalf("missing %q in:\n%s", want, 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 this chat") || !strings.Contains(got, "00:00 UTC+7") {
- t.Fatalf("first reply = %q, want subscribed with 00:00 UTC+7", 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)
-
- if got := rb.LastSent().Text(); !strings.Contains(got, "Subscribed this topic") {
- t.Fatalf("topic subscribe reply = %q", got)
- }
- 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 TestHandleSchedule_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", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule}
- 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"))
- 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
deleted file mode 100644
index e73ee22..0000000
--- a/internal/modules/wc/parse_date.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package wc
-
-import (
- "fmt"
- "regexp"
- "strconv"
- "strings"
- "time"
-)
-
-const ictOffset = 7 * time.Hour
-
-const formatHint = "Use dd, dd-mm, dd/mm, ddmm, 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
deleted file mode 100644
index 3053a1b..0000000
--- a/internal/modules/wc/parse_date_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-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
deleted file mode 100644
index 1784c1f..0000000
--- a/internal/modules/wc/subscribers.go
+++ /dev/null
@@ -1,98 +0,0 @@
-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
deleted file mode 100644
index d02b6b3..0000000
--- a/internal/modules/wc/subscribers_test.go
+++ /dev/null
@@ -1,58 +0,0 @@
-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
deleted file mode 100644
index 20e4b54..0000000
--- a/internal/modules/wc/terminal.go
+++ /dev/null
@@ -1,47 +0,0 @@
-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
deleted file mode 100644
index ff72da6..0000000
--- a/internal/modules/wc/types.go
+++ /dev/null
@@ -1,55 +0,0 @@
-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
deleted file mode 100644
index fa516b9..0000000
--- a/internal/modules/wc/wc.go
+++ /dev/null
@@ -1,45 +0,0 @@
-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, dd-mm, dd/mm, ddmm, or full date; default today)",
- Handler: s.handleSchedule,
- },
- {
- Name: "wc_this_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 00:00 UTC+7",
- 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
deleted file mode 100644
index 36a75e2..0000000
--- a/internal/modules/wc/wc_test.go
+++ /dev/null
@@ -1,24 +0,0 @@
-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_this_week", "wc_subscribe", "wc_unsubscribe"} {
- if !got[name] {
- t.Fatalf("missing command %s", name)
- }
- }
- if len(mod.Crons) != 1 || mod.Crons[0].Name != dailyPushCronName || mod.Crons[0].Schedule != "0 17 * * *" {
- t.Fatalf("crons = %+v, want %s at 00:00 UTC+7", mod.Crons, dailyPushCronName)
- }
-}
diff --git a/telegram-commands.json b/telegram-commands.json
index 369bf4e..658140e 100644
--- a/telegram-commands.json
+++ b/telegram-commands.json
@@ -84,22 +84,6 @@
"command": "lol_unsubscribe",
"description": "Stop the daily LoL schedule digest"
},
- {
- "command": "wc",
- "description": "World Cup matches for a date; supports dd, dd-mm, dd/mm, ddmm"
- },
- {
- "command": "wc_this_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": "stock_price",
"description": "Show current VN stock price"