feat(wc): add world cup schedule module

This commit is contained in:
tiennm99 committed 2026-06-29 00:59:30 +07:00
1 parent 5dcf94a652
commit 5971347a6b
24 files changed
+1936 -9

No files matched your search

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