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"