feat(modules): remove world cup module

The 2026 tournament has ended, so the schedule and daily digest commands
have no upcoming matches to report. Drops the module, its catalog entry,
command menu entries, and the WC_FOOTBALL_DATA_TOKEN env var.

Stored subscriber and match-cache documents are left in place.
This commit is contained in:
2026-07-20 11:03:59 +07:00
parent de76a74c32
commit 6ef9c2c4cf
22 changed files with 2 additions and 1692 deletions
-5
View File
@@ -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.
-3
View File
@@ -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
-2
View File
@@ -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,
+2 -3
View File
@@ -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)
-1
View File
@@ -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` |
-172
View File
@@ -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] + "..."
}
-159
View File
@@ -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("<html>not json</html>"))
}))
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)
}
}
-175
View File
@@ -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
}
-146
View File
@@ -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)
}
}
-176
View File
@@ -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 = "<b>" + home + "</b>"
}
if m.Score.Winner == "AWAY_TEAM" {
away = "<b>" + away + "</b>"
}
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 := "<b>World Cup - " + html.EscapeString(formatIctDayLabel(day)) + "</b> (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 := "<b>World Cup - " + fromLbl + " -> " + toLbl + "</b> (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] = "<i>" + html.EscapeString(d.Label) + "</i>\n" + strings.Join(d.Lines, "\n")
}
return header + "\n\n" + strings.Join(sections, "\n\n")
}
-82
View File
@@ -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", "<b>MEX</b>", "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)
}
}
}
-128
View File
@@ -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.")
}
-143
View File
@@ -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{"<b>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)
}
}
-118
View File
@@ -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()}
}
-36
View File
@@ -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)
}
}
-98
View File
@@ -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
}
-58
View File
@@ -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)
}
}
-47
View File
@@ -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)")
-55
View File
@@ -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]
-45
View File
@@ -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()},
}
}
-24
View File
@@ -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)
}
}
-16
View File
@@ -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"