mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
feat(wc): add world cup schedule module
This commit is contained in:
1 parent
5dcf94a652
commit
5971347a6b
24 files changed
+1936
-9
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
@@ -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]
|
||||
@@ -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()},
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user