mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-05 18:24:54 +00:00
feat(modules): remove world cup module
The 2026 tournament has ended, so the schedule and daily digest commands have no upcoming matches to report. Drops the module, its catalog entry, command menu entries, and the WC_FOOTBALL_DATA_TOKEN env var. Stored subscriber and match-cache documents are left in place.
This commit is contained in:
@@ -19,11 +19,6 @@ OWNER_ID=
|
||||
# Comma-separated admin Telegram user ids (renamed from ADMIN_USER_IDS).
|
||||
ADMIN_IDS=
|
||||
|
||||
# The wc module uses football-data.org for World Cup schedule/live-score data.
|
||||
# Leave blank to keep /wc commands loaded but replying with a not-configured
|
||||
# message.
|
||||
WC_FOOTBALL_DATA_TOKEN=
|
||||
|
||||
# SOURCE_COMMIT (commit SHA) is read at startup for the deploynotify owner DM.
|
||||
# Do NOT set it here. Coolify provides it at runtime. Keep "Include Source
|
||||
# Commit in Build" disabled so Docker layer cache survives across commits.
|
||||
|
||||
@@ -12,7 +12,6 @@ Atlas via long polling and an in-process cron scheduler.
|
||||
| `wordle` | Daily Wordle game |
|
||||
| `loldle` | League-of-Legends "guess the champion" |
|
||||
| `lol` | Pro-match schedule (`/lol`, `/lol_tomorrow`, `/lol_this_week`, `/lol_next_week`) + daily push |
|
||||
| `wc` | World Cup schedule + silent daily push |
|
||||
| `stock` | VN-stocks paper trading |
|
||||
| `gold` | Gold paper trading (opt-in; VNAppMob SJC buy/sell VND/luong) |
|
||||
| `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) |
|
||||
@@ -41,7 +40,6 @@ In-memory storage (no database required):
|
||||
|
||||
```sh
|
||||
TELEGRAM_BOT_TOKEN=… \
|
||||
WC_FOOTBALL_DATA_TOKEN=… \
|
||||
MODULES= \
|
||||
go run ./cmd/server
|
||||
```
|
||||
@@ -53,7 +51,6 @@ Persistent MongoDB locally (auto-selected when `MONGO_URL` is set):
|
||||
```sh
|
||||
make mongo-local
|
||||
TELEGRAM_BOT_TOKEN=… \
|
||||
WC_FOOTBALL_DATA_TOKEN=… \
|
||||
MONGO_URL=mongodb://127.0.0.1:27017 \
|
||||
MONGO_DATABASE=miti99bot_dev \
|
||||
go run ./cmd/server
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
"github.com/tiennm99/miti99bot/internal/modules/stats"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/stock"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/util"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/wc"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/wordle"
|
||||
"github.com/tiennm99/miti99bot/internal/server"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
@@ -62,7 +61,6 @@ func factories() map[string]modules.Factory {
|
||||
"wordle": wordle.New,
|
||||
"loldle": loldle.New,
|
||||
lol.CollectionName: lol.New,
|
||||
"wc": wc.New,
|
||||
"coin": coin.New,
|
||||
"gold": gold.New,
|
||||
"stock": stock.New,
|
||||
|
||||
@@ -50,19 +50,18 @@ func TestComposeDoesNotOverrideSourceCommit(t *testing.T) {
|
||||
|
||||
func TestFactoriesIncludesExpectedModules(t *testing.T) {
|
||||
catalog := factories()
|
||||
for _, name := range []string{"gold", "coin", "wc"} {
|
||||
for _, name := range []string{"gold", "coin"} {
|
||||
if catalog[name] == nil {
|
||||
t.Fatalf("factories missing %s", name)
|
||||
}
|
||||
}
|
||||
reg, err := modules.Build([]string{"gold", "coin", "wc"}, catalog, storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
reg, err := modules.Build([]string{"gold", "coin"}, catalog, storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build selected modules: %v", err)
|
||||
}
|
||||
for _, name := range []string{
|
||||
"gold_price", "gold_topup", "gold_buy", "gold_sell", "gold_portfolio",
|
||||
"coin_price", "coin_topup", "coin_buy", "coin_sell", "coin_portfolio",
|
||||
"wc", "wc_this_week", "wc_subscribe", "wc_unsubscribe",
|
||||
} {
|
||||
if _, ok := reg.AllCommands[name]; !ok {
|
||||
t.Fatalf("missing command %s", name)
|
||||
|
||||
@@ -33,7 +33,6 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in.
|
||||
| `MODULES` | optional | CSV; empty = all modules |
|
||||
| `OWNER_ID` | optional | owner-only commands (renamed from `BOT_OWNER_ID`) |
|
||||
| `ADMIN_IDS` | optional | CSV of admin ids (renamed from `ADMIN_USER_IDS`) |
|
||||
| `WC_FOOTBALL_DATA_TOKEN` | optional | football-data.org token for the `wc` module |
|
||||
| `WHEELOFNAMES_API_URL` | optional | full `/api/gif` endpoint for remote `/wheelofnames` GIF rendering |
|
||||
| `WHEELOFNAMES_API_TOKEN` | optional | bearer token matching the wheelofnames service `API_TOKEN` |
|
||||
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
)
|
||||
|
||||
const (
|
||||
apiURL = "https://api.football-data.org/v4/competitions/WC/matches"
|
||||
userAgent = "miti99bot/0.1 (https://t.me/miti99bot)"
|
||||
worldCupYear = "2026"
|
||||
|
||||
staleMaxAge = 24 * time.Hour
|
||||
httpTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
// ErrNotConfigured is returned when no football-data.org token is available.
|
||||
var ErrNotConfigured = errors.New("wc: WC_FOOTBALL_DATA_TOKEN not set")
|
||||
|
||||
// Client talks to football-data.org. Tests inject URL/HTTP/Token.
|
||||
type Client struct {
|
||||
HTTP *http.Client
|
||||
URL string
|
||||
Token string
|
||||
}
|
||||
|
||||
// NewClientFromEnv builds the production World Cup API client.
|
||||
func NewClientFromEnv() *Client {
|
||||
return &Client{Token: strings.TrimSpace(os.Getenv("WC_FOOTBALL_DATA_TOKEN"))}
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
return &http.Client{Timeout: httpTimeout}
|
||||
}
|
||||
|
||||
func (c *Client) baseURL() string {
|
||||
if c.URL != "" {
|
||||
return c.URL
|
||||
}
|
||||
return apiURL
|
||||
}
|
||||
|
||||
func (c *Client) token() string {
|
||||
return strings.TrimSpace(c.Token)
|
||||
}
|
||||
|
||||
func (c *Client) fetchAllMatches(ctx context.Context) ([]Match, error) {
|
||||
if c.token() == "" {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
u, err := url.Parse(c.baseURL())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wc parse url: %w", err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("season", worldCupYear)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wc build request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-Auth-Token", c.token())
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wc do: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wc read: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
log.Warn("wc_fetch", "status", resp.StatusCode, "body", truncateLog(string(body), 500))
|
||||
return nil, fmt.Errorf("wc API HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var out matchesResponse
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("wc decode: %w", err)
|
||||
}
|
||||
sortMatches(out.Matches)
|
||||
return out.Matches, nil
|
||||
}
|
||||
|
||||
func cacheKey() string {
|
||||
return "matches:" + worldCupYear
|
||||
}
|
||||
|
||||
// GetMatchesCached returns matches in [from, to). It is live-first: every call
|
||||
// attempts football-data.org so TBD fixtures and live scores update quickly.
|
||||
// The stored full-tournament payload is only a stale fallback when upstream
|
||||
// fails.
|
||||
func (c *Client) GetMatchesCached(ctx context.Context, cache CacheStore, from, to time.Time) ([]Match, error) {
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
cached, _, cacheErr := cache.Get(ctx, cacheKey())
|
||||
hasCached := cacheErr == nil
|
||||
|
||||
matches, fetchErr := c.fetchAllMatches(ctx)
|
||||
if fetchErr == nil {
|
||||
rec := cacheRecord{Ts: now, Matches: matches}
|
||||
if err := cache.Put(ctx, cacheKey(), rec); err != nil {
|
||||
log.Warn("wc_cache_put_fail", "err", err)
|
||||
}
|
||||
return filterMatches(matches, from, to), nil
|
||||
}
|
||||
|
||||
if hasCached && now-cached.Ts < staleMaxAge.Milliseconds() {
|
||||
log.Warn("wc_stale_fallback", "err", fetchErr)
|
||||
return filterMatches(cached.Matches, from, to), nil
|
||||
}
|
||||
return nil, fetchErr
|
||||
}
|
||||
|
||||
func filterMatches(matches []Match, from, to time.Time) []Match {
|
||||
out := make([]Match, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
t, err := time.Parse(time.RFC3339, m.UTCDate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !t.Before(from) && t.Before(to) {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
sortMatches(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortMatches(matches []Match) {
|
||||
sort.SliceStable(matches, func(i, j int) bool {
|
||||
ti, errI := time.Parse(time.RFC3339, matches[i].UTCDate)
|
||||
tj, errJ := time.Parse(time.RFC3339, matches[j].UTCDate)
|
||||
if errI != nil || errJ != nil {
|
||||
return matches[i].ID < matches[j].ID
|
||||
}
|
||||
if ti.Equal(tj) {
|
||||
return matches[i].ID < matches[j].ID
|
||||
}
|
||||
return ti.Before(tj)
|
||||
})
|
||||
}
|
||||
|
||||
func truncateLog(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
cut := maxLen
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "..."
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
func newCacheStore() CacheStore {
|
||||
return storage.Typed[cacheRecord](storage.NewMemoryProvider().Collection("wc"))
|
||||
}
|
||||
|
||||
func mkServer(t *testing.T, body string) (*httptest.Server, *int32) {
|
||||
t.Helper()
|
||||
var count int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&count, 1)
|
||||
if got := r.Header.Get("X-Auth-Token"); got != "secret-token" {
|
||||
t.Errorf("X-Auth-Token = %q, want secret-token", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("season"); got != worldCupYear {
|
||||
t.Errorf("season = %q, want %s", got, worldCupYear)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &count
|
||||
}
|
||||
|
||||
const sampleMatchesBody = `{
|
||||
"matches": [
|
||||
{
|
||||
"id": 1,
|
||||
"utcDate": "2026-06-12T13:00:00Z",
|
||||
"status": "TIMED",
|
||||
"stage": "GROUP_STAGE",
|
||||
"group": "GROUP_A",
|
||||
"venue": "Estadio Azteca",
|
||||
"homeTeam": {"name": "Mexico", "shortName": "Mexico", "tla": "MEX"},
|
||||
"awayTeam": {"name": "South Africa", "shortName": "South Africa", "tla": "RSA"},
|
||||
"score": {"winner": null, "fullTime": {"home": null, "away": null}}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"utcDate": "2026-06-13T13:00:00Z",
|
||||
"status": "TIMED",
|
||||
"stage": "GROUP_STAGE",
|
||||
"group": "GROUP_B",
|
||||
"homeTeam": {"tla": "CAN"},
|
||||
"awayTeam": {"tla": "SUI"},
|
||||
"score": {"winner": null, "fullTime": {"home": null, "away": null}}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestGetMatchesCached_FetchesAndFilters(t *testing.T) {
|
||||
srv, count := mkServer(t, sampleMatchesBody)
|
||||
c := &Client{HTTP: srv.Client(), URL: srv.URL, Token: "secret-token"}
|
||||
cache := newCacheStore()
|
||||
from := time.Date(2026, 6, 12, 0, 0, 0, 0, IctLocation).UTC()
|
||||
to := addDays(from, 1)
|
||||
|
||||
matches, err := c.GetMatchesCached(context.Background(), cache, from, to)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if len(matches) != 1 || matches[0].HomeTeam.TLA != "MEX" {
|
||||
t.Fatalf("matches = %+v, want only MEX match", matches)
|
||||
}
|
||||
if got := atomic.LoadInt32(count); got != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMatchesCached_AlwaysRefetchesWhenProviderIsAvailable(t *testing.T) {
|
||||
var count int32
|
||||
firstBody := `{"matches":[{"id":1,"utcDate":"2026-06-12T13:00:00Z","status":"TIMED","homeTeam":{"tla":"MEX"},"awayTeam":{"tla":"RSA"}}]}`
|
||||
secondBody := `{"matches":[{"id":1,"utcDate":"2026-06-12T13:00:00Z","status":"TIMED","homeTeam":{"tla":"BRA"},"awayTeam":{"tla":"RSA"}}]}`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
n := atomic.AddInt32(&count, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if n == 1 {
|
||||
_, _ = w.Write([]byte(firstBody))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(secondBody))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := &Client{HTTP: srv.Client(), URL: srv.URL, Token: "secret-token"}
|
||||
cache := newCacheStore()
|
||||
|
||||
firstFrom := time.Date(2026, 6, 12, 0, 0, 0, 0, IctLocation).UTC()
|
||||
if _, err := c.GetMatchesCached(context.Background(), cache, firstFrom, addDays(firstFrom, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
matches, err := c.GetMatchesCached(context.Background(), cache, firstFrom, addDays(firstFrom, 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 1 || matches[0].HomeTeam.TLA != "BRA" {
|
||||
t.Fatalf("second fetch = %+v, want refreshed BRA match", matches)
|
||||
}
|
||||
if got := atomic.LoadInt32(&count); got != 2 {
|
||||
t.Fatalf("upstream calls = %d, want 2 live fetches", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMatchesCached_StaleFallback(t *testing.T) {
|
||||
cache := newCacheStore()
|
||||
from := time.Date(2026, 6, 12, 0, 0, 0, 0, IctLocation).UTC()
|
||||
stale := []Match{{ID: 9, UTCDate: "2026-06-12T13:00:00Z", HomeTeam: Team{TLA: "MEX"}}}
|
||||
rec := cacheRecord{Ts: time.Now().UTC().Add(-time.Hour).UnixMilli(), Matches: stale}
|
||||
if err := cache.Put(context.Background(), cacheKey(), rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"down"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := &Client{HTTP: srv.Client(), URL: srv.URL, Token: "secret-token"}
|
||||
|
||||
matches, err := c.GetMatchesCached(context.Background(), cache, from, addDays(from, 1))
|
||||
if err != nil {
|
||||
t.Fatalf("stale fallback: %v", err)
|
||||
}
|
||||
if len(matches) != 1 || matches[0].ID != 9 {
|
||||
t.Fatalf("matches = %+v, want stale match", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMatchesCached_MissingToken(t *testing.T) {
|
||||
c := &Client{Token: ""}
|
||||
from := time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC)
|
||||
_, err := c.GetMatchesCached(context.Background(), newCacheStore(), from, addDays(from, 1))
|
||||
if !errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("err = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllMatches_NonJSONErrors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("<html>not json</html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := &Client{HTTP: srv.Client(), URL: srv.URL, Token: "secret-token"}
|
||||
_, err := c.fetchAllMatches(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "decode") {
|
||||
t.Fatalf("err = %v, want decode error", err)
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
const dailyPushCronName = "wc_daily_push"
|
||||
|
||||
// 17:00 UTC is 00:00 UTC+7 (ICT).
|
||||
const dailyPushSchedule = "0 17 * * *"
|
||||
|
||||
const lastPushDateKey = "daily_push:last_date"
|
||||
|
||||
const telegramRateLimitThreshold = 30
|
||||
|
||||
const telegramRateLimitDelay = 50 * time.Millisecond
|
||||
|
||||
type messageSender interface {
|
||||
SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
|
||||
}
|
||||
|
||||
type lastPushDoc struct {
|
||||
Date string `json:"date" bson:"date"`
|
||||
}
|
||||
|
||||
// PushDateStore is the typed store for last-push date documents.
|
||||
type PushDateStore = storage.DocStore[lastPushDoc]
|
||||
|
||||
func (s *state) dailyPushCron() modules.Cron {
|
||||
return modules.Cron{
|
||||
Name: dailyPushCronName,
|
||||
Schedule: dailyPushSchedule,
|
||||
Handler: s.dailyPushHandler,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *state) dailyPushHandler(ctx context.Context, deps modules.Deps) error {
|
||||
if deps.Bot == nil {
|
||||
return errNilBot
|
||||
}
|
||||
return runDailyPush(ctx, s, deps.Bot)
|
||||
}
|
||||
|
||||
func claimDailyPush(ctx context.Context, store PushDateStore, pushDay string) (bool, error) {
|
||||
current, version, err := store.Get(ctx, lastPushDateKey)
|
||||
switch {
|
||||
case err == nil:
|
||||
if current.Date == pushDay {
|
||||
return false, nil
|
||||
}
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
version = 0
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := store.PutVersioned(ctx, lastPushDateKey, version, lastPushDoc{Date: pushDay}); err != nil {
|
||||
if errors.Is(err, storage.ErrConflict) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func runDailyPush(ctx context.Context, s *state, sender messageSender) error {
|
||||
subs, err := listSubscribers(ctx, s.subscribers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wc daily push: list subscribers: %w", err)
|
||||
}
|
||||
if len(subs) == 0 {
|
||||
log.Info("wc daily push: no subscribers, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
from := ictDayStartOf(s.now())
|
||||
to := addDays(from, 1)
|
||||
matches, err := s.client.GetMatchesCached(ctx, s.cache, from, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wc daily push: fetch matches: %w", err)
|
||||
}
|
||||
text := RenderToday(matches, from)
|
||||
|
||||
pushDay := ictDayKey(from)
|
||||
won, err := claimDailyPush(ctx, s.pushDate, pushDay)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wc daily push: claim date: %w", err)
|
||||
}
|
||||
if !won {
|
||||
log.Info("wc daily push: already pushed today, skipping", "date", pushDay)
|
||||
return nil
|
||||
}
|
||||
|
||||
throttle := len(subs) > telegramRateLimitThreshold
|
||||
var sent, failed int
|
||||
deadChats := map[int64]struct{}{}
|
||||
var deadTopics []Subscriber
|
||||
for i, sub := range subs {
|
||||
if throttle && i > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(telegramRateLimitDelay):
|
||||
}
|
||||
}
|
||||
if _, err := sender.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: sub.ChatID,
|
||||
MessageThreadID: sub.ThreadID,
|
||||
Text: text,
|
||||
ParseMode: models.ParseModeHTML,
|
||||
DisableNotification: true,
|
||||
}); err != nil {
|
||||
log.Warn("wc daily push send failed", "chat", sub.ChatID, "thread", sub.ThreadID, "err", err)
|
||||
failed++
|
||||
switch classifyTerminal(err) {
|
||||
case terminalChatWide:
|
||||
deadChats[sub.ChatID] = struct{}{}
|
||||
case terminalTopicOnly:
|
||||
deadTopics = append(deadTopics, sub)
|
||||
}
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
}
|
||||
|
||||
pruned := pruneDeadSubscribers(ctx, s, deadChats, deadTopics)
|
||||
log.Info("wc daily push complete",
|
||||
"subscribers", len(subs),
|
||||
"sent", sent,
|
||||
"failed", failed,
|
||||
"pruned", pruned,
|
||||
"throttled", throttle)
|
||||
return nil
|
||||
}
|
||||
|
||||
func pruneDeadSubscribers(ctx context.Context, s *state, chatWide map[int64]struct{}, topicOnly []Subscriber) int {
|
||||
if len(chatWide) == 0 && len(topicOnly) == 0 {
|
||||
return 0
|
||||
}
|
||||
s.subscribersMu.Lock()
|
||||
defer s.subscribersMu.Unlock()
|
||||
removed := 0
|
||||
for chatID := range chatWide {
|
||||
n, err := removeAllForChat(ctx, s.subscribers, chatID)
|
||||
if err != nil {
|
||||
log.Warn("wc prune dead chat failed", "chat", chatID, "err", err)
|
||||
continue
|
||||
}
|
||||
removed += n
|
||||
}
|
||||
for _, sub := range topicOnly {
|
||||
if _, ok := chatWide[sub.ChatID]; ok {
|
||||
continue
|
||||
}
|
||||
ok, err := removeSubscriber(ctx, s.subscribers, sub.ChatID, sub.ThreadID)
|
||||
if err != nil {
|
||||
log.Warn("wc prune dead topic failed", "chat", sub.ChatID, "thread", sub.ThreadID, "err", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
type fakeSender struct {
|
||||
mu sync.Mutex
|
||||
calls []bot.SendMessageParams
|
||||
terminalOn map[int64]bool
|
||||
topicOnlyOn map[int64]bool
|
||||
transientOn map[int64]bool
|
||||
}
|
||||
|
||||
func (f *fakeSender) SendMessage(_ context.Context, p *bot.SendMessageParams) (*models.Message, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, *p)
|
||||
id, _ := p.ChatID.(int64)
|
||||
if f.terminalOn[id] {
|
||||
return nil, errors.New("Forbidden: bot was blocked by the user")
|
||||
}
|
||||
if f.topicOnlyOn[id] {
|
||||
return nil, errors.New("Bad Request: have no rights to send a message")
|
||||
}
|
||||
if f.transientOn[id] {
|
||||
return nil, errors.New("connection reset by peer")
|
||||
}
|
||||
return &models.Message{}, nil
|
||||
}
|
||||
|
||||
func newTestStores() (SubscriberStore, PushDateStore, CacheStore) {
|
||||
col := storage.NewMemoryProvider().Collection("wc")
|
||||
return storage.Typed[subscribersDoc](col),
|
||||
storage.Typed[lastPushDoc](col),
|
||||
storage.Typed[cacheRecord](col)
|
||||
}
|
||||
|
||||
func newTestState() *state {
|
||||
subs, pd, cache := newTestStores()
|
||||
return &state{
|
||||
subscribers: subs,
|
||||
pushDate: pd,
|
||||
cache: cache,
|
||||
client: &Client{},
|
||||
nowFn: func() time.Time { return fakeNow },
|
||||
}
|
||||
}
|
||||
|
||||
func seedFreshCache(t *testing.T, cache CacheStore, matches []Match) {
|
||||
t.Helper()
|
||||
rec := cacheRecord{Ts: time.Now().UTC().UnixMilli(), Matches: matches}
|
||||
if err := cache.Put(context.Background(), cacheKey(), rec); err != nil {
|
||||
t.Fatalf("seed cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDailyPush_SendsAndIsIdempotent(t *testing.T) {
|
||||
s := newTestState()
|
||||
seedFreshCache(t, s.cache, []Match{mkMatch("TIMED", "MEX", "RSA", "2026-06-12T13:00:00Z")})
|
||||
if _, err := addSubscriber(context.Background(), s.subscribers, 100, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sender := &fakeSender{}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := runDailyPush(context.Background(), s, sender); err != nil {
|
||||
t.Fatalf("runDailyPush %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if len(sender.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(sender.calls))
|
||||
}
|
||||
call := sender.calls[0]
|
||||
if call.MessageThreadID != 7 || call.ParseMode != models.ParseModeHTML || !call.DisableNotification {
|
||||
t.Fatalf("call = %+v, want thread 7 HTML silent notification", call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDailyPush_ClaimsICTDayAtMidnight(t *testing.T) {
|
||||
s := newTestState()
|
||||
s.nowFn = func() time.Time {
|
||||
return time.Date(2026, 6, 12, 17, 0, 0, 0, time.UTC) // 2026-06-13 00:00 ICT
|
||||
}
|
||||
seedFreshCache(t, s.cache, []Match{mkMatch("TIMED", "MEX", "RSA", "2026-06-12T18:00:00Z")})
|
||||
if _, err := addSubscriber(context.Background(), s.subscribers, 100, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.pushDate.Put(context.Background(), lastPushDateKey, lastPushDoc{Date: "2026-06-12"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sender := &fakeSender{}
|
||||
if err := runDailyPush(context.Background(), s, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sender.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1 midnight ICT push", len(sender.calls))
|
||||
}
|
||||
doc, _, err := s.pushDate.Get(context.Background(), lastPushDateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.Date != "2026-06-13" {
|
||||
t.Fatalf("last push date = %q, want ICT day 2026-06-13", doc.Date)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDailyPush_PrunesDeadChat(t *testing.T) {
|
||||
s := newTestState()
|
||||
seedFreshCache(t, s.cache, nil)
|
||||
for _, sub := range []Subscriber{{ChatID: 100}, {ChatID: 200}, {ChatID: 200, ThreadID: 9}} {
|
||||
if _, err := addSubscriber(context.Background(), s.subscribers, sub.ChatID, sub.ThreadID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sender := &fakeSender{terminalOn: map[int64]bool{200: true}}
|
||||
if err := runDailyPush(context.Background(), s, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remaining, _ := listSubscribers(context.Background(), s.subscribers)
|
||||
if len(remaining) != 1 || remaining[0].ChatID != 100 {
|
||||
t.Fatalf("remaining = %v, want only chat 100", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyPushCronRegistrationAndNilBot(t *testing.T) {
|
||||
s := newTestState()
|
||||
c := s.dailyPushCron()
|
||||
if c.Name != dailyPushCronName || c.Schedule != "0 17 * * *" || c.Handler == nil {
|
||||
t.Fatalf("cron = %+v", c)
|
||||
}
|
||||
err := s.dailyPushHandler(context.Background(), modules.Deps{Store: storage.NewMemoryProvider().Collection("wc")})
|
||||
if !errors.Is(err, errNilBot) {
|
||||
t.Fatalf("err = %v, want errNilBot", err)
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func formatIctTime(t time.Time) string {
|
||||
d := t.In(IctLocation)
|
||||
return fmt.Sprintf("%02d:%02d", d.Hour(), d.Minute())
|
||||
}
|
||||
|
||||
func formatIctDayLabel(t time.Time) string {
|
||||
weekdays := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
|
||||
months := []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
|
||||
d := t.In(IctLocation)
|
||||
return fmt.Sprintf("%s %s %d", weekdays[d.Weekday()], months[d.Month()-1], d.Day())
|
||||
}
|
||||
|
||||
func ictDayKey(t time.Time) string {
|
||||
d := t.In(IctLocation)
|
||||
return fmt.Sprintf("%04d-%02d-%02d", d.Year(), int(d.Month()), d.Day())
|
||||
}
|
||||
|
||||
func teamLabel(t Team) string {
|
||||
for _, s := range []string{t.TLA, t.ShortName, t.Name} {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" && !strings.EqualFold(s, "TBD") {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return "TBD"
|
||||
}
|
||||
|
||||
func stageLabel(stage, group string) string {
|
||||
if strings.TrimSpace(group) != "" {
|
||||
return titleToken(group)
|
||||
}
|
||||
switch stage {
|
||||
case "GROUP_STAGE":
|
||||
return "Group Stage"
|
||||
case "LAST_16":
|
||||
return "Last 16"
|
||||
case "QUARTER_FINALS":
|
||||
return "Quarter-finals"
|
||||
case "SEMI_FINALS":
|
||||
return "Semi-finals"
|
||||
case "THIRD_PLACE":
|
||||
return "Third Place"
|
||||
case "FINAL":
|
||||
return "Final"
|
||||
default:
|
||||
return titleToken(stage)
|
||||
}
|
||||
}
|
||||
|
||||
func titleToken(s string) string {
|
||||
s = strings.TrimSpace(strings.ReplaceAll(s, "_", " "))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Fields(strings.ToLower(s))
|
||||
for i, p := range parts {
|
||||
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func scorePair(score Score) (int, int, bool) {
|
||||
if score.FullTime.Home != nil && score.FullTime.Away != nil {
|
||||
return *score.FullTime.Home, *score.FullTime.Away, true
|
||||
}
|
||||
if score.HalfTime.Home != nil && score.HalfTime.Away != nil {
|
||||
return *score.HalfTime.Home, *score.HalfTime.Away, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func formatMatchLine(m Match) string {
|
||||
t, err := time.Parse(time.RFC3339, m.UTCDate)
|
||||
if err != nil {
|
||||
t = time.Time{}
|
||||
}
|
||||
home := html.EscapeString(teamLabel(m.HomeTeam))
|
||||
away := html.EscapeString(teamLabel(m.AwayTeam))
|
||||
meta := stageLabel(m.Stage, m.Group)
|
||||
if m.Venue != "" {
|
||||
if meta != "" {
|
||||
meta += " - "
|
||||
}
|
||||
meta += m.Venue
|
||||
}
|
||||
if meta != "" {
|
||||
meta = " (" + html.EscapeString(meta) + ")"
|
||||
}
|
||||
|
||||
homeGoals, awayGoals, hasScore := scorePair(m.Score)
|
||||
switch m.Status {
|
||||
case "IN_PLAY", "PAUSED":
|
||||
if hasScore {
|
||||
return fmt.Sprintf("LIVE %s %d-%d %s%s", home, homeGoals, awayGoals, away, meta)
|
||||
}
|
||||
return fmt.Sprintf("LIVE %s vs %s%s", home, away, meta)
|
||||
case "FINISHED", "AWARDED":
|
||||
if m.Score.Winner == "HOME_TEAM" {
|
||||
home = "<b>" + home + "</b>"
|
||||
}
|
||||
if m.Score.Winner == "AWAY_TEAM" {
|
||||
away = "<b>" + away + "</b>"
|
||||
}
|
||||
if hasScore {
|
||||
return fmt.Sprintf("FT %s %d-%d %s%s", home, homeGoals, awayGoals, away, meta)
|
||||
}
|
||||
return fmt.Sprintf("FT %s vs %s%s", home, away, meta)
|
||||
case "POSTPONED", "SUSPENDED", "CANCELLED":
|
||||
return fmt.Sprintf("%s %s vs %s%s", strings.ReplaceAll(m.Status, "_", " "), home, away, meta)
|
||||
default:
|
||||
return fmt.Sprintf("%s %s vs %s%s", formatIctTime(t), home, away, meta)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderToday renders all World Cup matches on one ICT day.
|
||||
func RenderToday(matches []Match, day time.Time) string {
|
||||
header := "<b>World Cup - " + html.EscapeString(formatIctDayLabel(day)) + "</b> (ICT)"
|
||||
if len(matches) == 0 {
|
||||
return header + "\nNo matches today."
|
||||
}
|
||||
lines := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
lines[i] = formatMatchLine(m)
|
||||
}
|
||||
return header + "\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// RenderWeek renders matches grouped by ICT day.
|
||||
func RenderWeek(matches []Match, from, to time.Time) string {
|
||||
fromLbl := html.EscapeString(formatIctDayLabel(from))
|
||||
toLbl := html.EscapeString(formatIctDayLabel(to.Add(-time.Millisecond)))
|
||||
header := "<b>World Cup - " + fromLbl + " -> " + toLbl + "</b> (ICT)"
|
||||
if len(matches) == 0 {
|
||||
return header + "\nNo matches this week."
|
||||
}
|
||||
|
||||
type dayBucket struct {
|
||||
Label string
|
||||
Lines []string
|
||||
}
|
||||
days := map[string]*dayBucket{}
|
||||
for _, m := range matches {
|
||||
t, err := time.Parse(time.RFC3339, m.UTCDate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
key := ictDayKey(t)
|
||||
d, ok := days[key]
|
||||
if !ok {
|
||||
d = &dayBucket{Label: formatIctDayLabel(t)}
|
||||
days[key] = d
|
||||
}
|
||||
d.Lines = append(d.Lines, formatMatchLine(m))
|
||||
}
|
||||
keys := make([]string, 0, len(days))
|
||||
for k := range days {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
sections := make([]string, len(keys))
|
||||
for i, k := range keys {
|
||||
d := days[k]
|
||||
sections[i] = "<i>" + html.EscapeString(d.Label) + "</i>\n" + strings.Join(d.Lines, "\n")
|
||||
}
|
||||
return header + "\n\n" + strings.Join(sections, "\n\n")
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func intPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
func mkMatch(status, home, away, startISO string) Match {
|
||||
return Match{
|
||||
ID: 1,
|
||||
UTCDate: startISO,
|
||||
Status: status,
|
||||
Stage: "GROUP_STAGE",
|
||||
Group: "GROUP_A",
|
||||
Venue: "Estadio Azteca",
|
||||
HomeTeam: Team{
|
||||
TLA: home,
|
||||
},
|
||||
AwayTeam: Team{
|
||||
TLA: away,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMatchLine_Scheduled(t *testing.T) {
|
||||
got := formatMatchLine(mkMatch("TIMED", "MEX", "RSA", "2026-06-12T13:00:00Z"))
|
||||
for _, want := range []string{"20:00", "MEX vs RSA", "Group A", "Estadio Azteca"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("missing %q in %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMatchLine_FinishedBoldsWinner(t *testing.T) {
|
||||
m := mkMatch("FINISHED", "MEX", "RSA", "2026-06-12T13:00:00Z")
|
||||
m.Score = Score{
|
||||
Winner: "HOME_TEAM",
|
||||
FullTime: ScoreValue{Home: intPtr(2), Away: intPtr(0)},
|
||||
}
|
||||
got := formatMatchLine(m)
|
||||
for _, want := range []string{"FT", "<b>MEX</b>", "2-0", "RSA"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("missing %q in %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMatchLine_LiveScore(t *testing.T) {
|
||||
m := mkMatch("IN_PLAY", "CAN", "SUI", "2026-06-13T13:00:00Z")
|
||||
m.Score = Score{FullTime: ScoreValue{Home: intPtr(1), Away: intPtr(1)}}
|
||||
got := formatMatchLine(m)
|
||||
if !strings.Contains(got, "LIVE CAN 1-1 SUI") {
|
||||
t.Fatalf("live line = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderToday_Empty(t *testing.T) {
|
||||
day := time.Date(2026, 6, 12, 0, 0, 0, 0, IctLocation)
|
||||
got := RenderToday(nil, day)
|
||||
if !strings.Contains(got, "World Cup") || !strings.Contains(got, "No matches today") {
|
||||
t.Fatalf("empty render = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWeek_GroupsByDay(t *testing.T) {
|
||||
from := time.Date(2026, 6, 12, 0, 0, 0, 0, IctLocation)
|
||||
matches := []Match{
|
||||
mkMatch("TIMED", "MEX", "RSA", "2026-06-12T13:00:00Z"),
|
||||
mkMatch("TIMED", "CAN", "SUI", "2026-06-13T13:00:00Z"),
|
||||
}
|
||||
got := RenderWeek(matches, from, addDays(from, 7))
|
||||
for _, want := range []string{"Fri Jun 12", "Sat Jun 13", "MEX vs RSA", "CAN vs SUI"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
|
||||
)
|
||||
|
||||
type state struct {
|
||||
subscribers SubscriberStore
|
||||
pushDate PushDateStore
|
||||
cache CacheStore
|
||||
client *Client
|
||||
nowFn func() time.Time
|
||||
|
||||
subscribersMu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *state) now() time.Time {
|
||||
if s.nowFn != nil {
|
||||
return s.nowFn()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// handleSchedule is /wc [date], defaulting to today in ICT.
|
||||
func (s *state) handleSchedule(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
parsed := ParseScheduleDate(arg, s.now())
|
||||
if !parsed.OK {
|
||||
return chathelper.Reply(ctx, b, msg, parsed.Error)
|
||||
}
|
||||
return s.replyForRange(ctx, b, msg, parsed.Date, addDays(parsed.Date, 1), false)
|
||||
}
|
||||
|
||||
func (s *state) handleWeek(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
from := ictWeekStartOf(s.now())
|
||||
return s.replyForRange(ctx, b, msg, from, addDays(from, 7), true)
|
||||
}
|
||||
|
||||
func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Message, from, to time.Time, week bool) error {
|
||||
matches, err := s.client.GetMatchesCached(ctx, s.cache, from, to)
|
||||
if err != nil {
|
||||
log.Error("wc_fetch_fail", "err", err, "from", from, "to", to)
|
||||
if errors.Is(err, ErrNotConfigured) {
|
||||
return chathelper.Reply(ctx, b, msg, "World Cup schedule is not configured (missing WC_FOOTBALL_DATA_TOKEN).")
|
||||
}
|
||||
hint := "Could not fetch World Cup matches. Try again later."
|
||||
if week {
|
||||
hint = "Could not fetch this week's World Cup matches. Try again later."
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg, hint)
|
||||
}
|
||||
var text string
|
||||
if week {
|
||||
text = RenderWeek(matches, from, to)
|
||||
} else {
|
||||
text = RenderToday(matches, from)
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg, text)
|
||||
}
|
||||
|
||||
func subscriptionScope(msg *models.Message) string {
|
||||
if msg != nil && msg.MessageThreadID != 0 {
|
||||
return "this topic"
|
||||
}
|
||||
return "this chat"
|
||||
}
|
||||
|
||||
func subscriptionScopeSentenceSubject(msg *models.Message) string {
|
||||
if msg != nil && msg.MessageThreadID != 0 {
|
||||
return "This topic"
|
||||
}
|
||||
return "This chat"
|
||||
}
|
||||
|
||||
func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
s.subscribersMu.Lock()
|
||||
defer s.subscribersMu.Unlock()
|
||||
added, err := addSubscriber(ctx, s.subscribers, msg.Chat.ID, msg.MessageThreadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scope := subscriptionScope(msg)
|
||||
if added {
|
||||
return chathelper.Reply(ctx, b, msg,
|
||||
"Subscribed "+scope+" to the daily World Cup schedule at 00:00 UTC+7.\n"+
|
||||
"If you block the bot, you'll be auto-unsubscribed on the next push.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg, "Already subscribed in "+scope+".")
|
||||
}
|
||||
|
||||
func (s *state) handleUnsubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
s.subscribersMu.Lock()
|
||||
defer s.subscribersMu.Unlock()
|
||||
removed, err := removeSubscriber(ctx, s.subscribers, msg.Chat.ID, msg.MessageThreadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scope := subscriptionScope(msg)
|
||||
if removed {
|
||||
return chathelper.Reply(ctx, b, msg, "Unsubscribed "+scope+".")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg, subscriptionScopeSentenceSubject(msg)+" wasn't subscribed.")
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
||||
)
|
||||
|
||||
func installWC(t *testing.T, bodyJSON string, now time.Time) (*testutil.RecordingBot, SubscriberStore) {
|
||||
t.Helper()
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(bodyJSON))
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
col := storage.NewMemoryProvider().Collection("wc")
|
||||
s := &state{
|
||||
subscribers: storage.Typed[subscribersDoc](col),
|
||||
pushDate: storage.Typed[lastPushDoc](col),
|
||||
cache: storage.Typed[cacheRecord](col),
|
||||
client: &Client{HTTP: upstream.Client(), URL: upstream.URL, Token: "secret-token"},
|
||||
nowFn: func() time.Time { return now },
|
||||
}
|
||||
mod := modules.Module{
|
||||
Name: "wc",
|
||||
Commands: []modules.Command{
|
||||
{Name: "wc", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule},
|
||||
{Name: "wc_this_week", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleWeek},
|
||||
{Name: "wc_subscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSubscribe},
|
||||
{Name: "wc_unsubscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleUnsubscribe},
|
||||
},
|
||||
}
|
||||
reg := &modules.Registry{Modules: []modules.Module{mod}, AllCommands: map[string]modules.Command{}}
|
||||
for _, c := range mod.Commands {
|
||||
reg.AllCommands[c.Name] = c
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{})
|
||||
return rb, s.subscribers
|
||||
}
|
||||
|
||||
var fakeNow = time.Date(2026, 6, 12, 5, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestHandleSchedule_DefaultsToToday(t *testing.T) {
|
||||
rb, _ := installWC(t, sampleMatchesBody, fakeNow)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wc"))
|
||||
|
||||
got := rb.LastSent()
|
||||
if got.Method != "sendMessage" {
|
||||
t.Fatalf("method = %q, want sendMessage", got.Method)
|
||||
}
|
||||
if got.Form["parse_mode"] != "HTML" {
|
||||
t.Fatalf("parse_mode = %q, want HTML", got.Form["parse_mode"])
|
||||
}
|
||||
for _, want := range []string{"<b>World Cup -", "MEX vs RSA", "Estadio Azteca"} {
|
||||
if !strings.Contains(got.Text(), want) {
|
||||
t.Fatalf("missing %q in:\n%s", want, got.Text())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSchedule_BadDateInput(t *testing.T) {
|
||||
rb, _ := installWC(t, sampleMatchesBody, fakeNow)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wc notadate"))
|
||||
if got := rb.LastSent().Text(); !strings.Contains(got, "Invalid date") {
|
||||
t.Fatalf("reply = %q, want invalid date", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWeek_RendersThisWeek(t *testing.T) {
|
||||
rb, _ := installWC(t, sampleMatchesBody, fakeNow)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wc_this_week"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
for _, want := range []string{"Mon Jun 8", "Sun Jun 14", "MEX vs RSA", "CAN vs SUI"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubscribe_AddsAndIsIdempotent(t *testing.T) {
|
||||
rb, store := installWC(t, sampleMatchesBody, fakeNow)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wc_subscribe"))
|
||||
if got := rb.LastSent().Text(); !strings.Contains(got, "Subscribed this chat") || !strings.Contains(got, "00:00 UTC+7") {
|
||||
t.Fatalf("first reply = %q, want subscribed with 00:00 UTC+7", got)
|
||||
}
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wc_subscribe"))
|
||||
if got := rb.LastSent().Text(); !strings.Contains(got, "Already subscribed") {
|
||||
t.Fatalf("second reply = %q, want already subscribed", got)
|
||||
}
|
||||
subs, _ := listSubscribers(context.Background(), store)
|
||||
if len(subs) != 1 || subs[0] != (Subscriber{ChatID: 7}) {
|
||||
t.Fatalf("subs = %v, want [{7 0}]", subs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubscribe_ForumTopic(t *testing.T) {
|
||||
rb, store := installWC(t, sampleMatchesBody, fakeNow)
|
||||
upd := testutil.NewSupergroupMessage(555, 999, "/wc_subscribe")
|
||||
upd.Message.MessageThreadID = 42
|
||||
rb.Bot.ProcessUpdate(context.Background(), upd)
|
||||
|
||||
if got := rb.LastSent().Text(); !strings.Contains(got, "Subscribed this topic") {
|
||||
t.Fatalf("topic subscribe reply = %q", got)
|
||||
}
|
||||
subs, _ := listSubscribers(context.Background(), store)
|
||||
if len(subs) != 1 || subs[0] != (Subscriber{ChatID: 555, ThreadID: 42}) {
|
||||
t.Fatalf("subs = %v, want topic subscription", subs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSchedule_MissingToken(t *testing.T) {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
col := storage.NewMemoryProvider().Collection("wc")
|
||||
s := &state{
|
||||
subscribers: storage.Typed[subscribersDoc](col),
|
||||
pushDate: storage.Typed[lastPushDoc](col),
|
||||
cache: storage.Typed[cacheRecord](col),
|
||||
client: &Client{},
|
||||
nowFn: func() time.Time { return fakeNow },
|
||||
}
|
||||
cmd := modules.Command{Name: "wc", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule}
|
||||
reg := &modules.Registry{
|
||||
Modules: []modules.Module{{Name: "wc", Commands: []modules.Command{cmd}}},
|
||||
AllCommands: map[string]modules.Command{cmd.Name: cmd},
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{})
|
||||
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wc"))
|
||||
if got := rb.LastSent().Text(); !strings.Contains(got, "WC_FOOTBALL_DATA_TOKEN") {
|
||||
t.Fatalf("reply = %q, want missing token hint", got)
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ictOffset = 7 * time.Hour
|
||||
|
||||
const formatHint = "Use dd, dd-mm, dd/mm, ddmm, dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy."
|
||||
|
||||
// IctLocation is the fixed-offset UTC+7 timezone for schedule display.
|
||||
var IctLocation = time.FixedZone("ICT", int(ictOffset/time.Second))
|
||||
|
||||
type parseDateResult struct {
|
||||
OK bool
|
||||
Date time.Time
|
||||
Error string
|
||||
}
|
||||
|
||||
var digitsOnly = regexp.MustCompile(`^\d+$`)
|
||||
|
||||
func ictDayStartOf(now time.Time) time.Time {
|
||||
ict := now.In(IctLocation)
|
||||
dayStart := time.Date(ict.Year(), ict.Month(), ict.Day(), 0, 0, 0, 0, IctLocation)
|
||||
return dayStart.UTC()
|
||||
}
|
||||
|
||||
func ictWeekStartOf(now time.Time) time.Time {
|
||||
day := ictDayStartOf(now).In(IctLocation)
|
||||
daysFromMonday := (int(day.Weekday()) + 6) % 7
|
||||
return day.AddDate(0, 0, -daysFromMonday).UTC()
|
||||
}
|
||||
|
||||
func addDays(date time.Time, days int) time.Time {
|
||||
return date.Add(time.Duration(days) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
func splitParts(trimmed string) ([]string, string) {
|
||||
if strings.ContainsAny(trimmed, "-/") {
|
||||
normalized := strings.ReplaceAll(trimmed, "/", "-")
|
||||
parts := strings.Split(normalized, "-")
|
||||
if len(parts) < 1 || len(parts) > 3 {
|
||||
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" || !digitsOnly.MatchString(p) {
|
||||
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
|
||||
}
|
||||
}
|
||||
return parts, ""
|
||||
}
|
||||
|
||||
if !digitsOnly.MatchString(trimmed) {
|
||||
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
|
||||
}
|
||||
switch len(trimmed) {
|
||||
case 1, 2:
|
||||
return []string{trimmed}, ""
|
||||
case 4:
|
||||
return []string{trimmed[:2], trimmed[2:]}, ""
|
||||
case 8:
|
||||
return []string{trimmed[:2], trimmed[2:4], trimmed[4:]}, ""
|
||||
default:
|
||||
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseScheduleDate parses a /wc date argument. Empty input means today in ICT.
|
||||
func ParseScheduleDate(input string, now time.Time) parseDateResult {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
return parseDateResult{OK: true, Date: ictDayStartOf(now)}
|
||||
}
|
||||
|
||||
parts, errMsg := splitParts(trimmed)
|
||||
if errMsg != "" {
|
||||
return parseDateResult{Error: errMsg}
|
||||
}
|
||||
|
||||
ictNow := now.In(IctLocation)
|
||||
day, _ := strconv.Atoi(parts[0])
|
||||
month := int(ictNow.Month())
|
||||
year := ictNow.Year()
|
||||
if len(parts) >= 2 {
|
||||
month, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
if len(parts) >= 3 {
|
||||
year, _ = strconv.Atoi(parts[2])
|
||||
}
|
||||
|
||||
if day < 1 || day > 31 {
|
||||
return parseDateResult{Error: fmt.Sprintf(`Invalid day %q - must be 1-31.`, parts[0])}
|
||||
}
|
||||
if month < 1 || month > 12 {
|
||||
monthStr := ""
|
||||
if len(parts) >= 2 {
|
||||
monthStr = parts[1]
|
||||
}
|
||||
return parseDateResult{Error: fmt.Sprintf(`Invalid month %q - must be 1-12.`, monthStr)}
|
||||
}
|
||||
if year < 1970 || year > 2100 {
|
||||
yearStr := ""
|
||||
if len(parts) >= 3 {
|
||||
yearStr = parts[2]
|
||||
}
|
||||
return parseDateResult{Error: fmt.Sprintf(`Invalid year %q.`, yearStr)}
|
||||
}
|
||||
|
||||
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, IctLocation)
|
||||
if candidate.Year() != year || int(candidate.Month()) != month || candidate.Day() != day {
|
||||
return parseDateResult{Error: fmt.Sprintf(`Invalid date - %d/%d/%d does not exist.`, day, month, year)}
|
||||
}
|
||||
return parseDateResult{OK: true, Date: candidate.UTC()}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var refNow = time.Date(2026, 6, 12, 5, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestParseScheduleDate(t *testing.T) {
|
||||
wantToday := time.Date(2026, 6, 12, 0, 0, 0, 0, IctLocation).UTC()
|
||||
if got := ParseScheduleDate("", refNow); !got.OK || !got.Date.Equal(wantToday) {
|
||||
t.Fatalf("empty = %+v, want %v", got, wantToday)
|
||||
}
|
||||
|
||||
wantFull := time.Date(2026, 7, 15, 0, 0, 0, 0, IctLocation).UTC()
|
||||
for _, in := range []string{"15-07-2026", "15/07/2026", "15072026"} {
|
||||
got := ParseScheduleDate(in, refNow)
|
||||
if !got.OK || !got.Date.Equal(wantFull) {
|
||||
t.Fatalf("%q = %+v, want %v", in, got, wantFull)
|
||||
}
|
||||
}
|
||||
|
||||
if got := ParseScheduleDate("notadate", refNow); got.OK || !strings.Contains(got.Error, "Invalid date") {
|
||||
t.Fatalf("invalid = %+v, want error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIctWeekStartOf(t *testing.T) {
|
||||
// refNow is Fri 2026-06-12 ICT. Monday is 2026-06-08 00:00 ICT.
|
||||
want := time.Date(2026, 6, 8, 0, 0, 0, 0, IctLocation).UTC()
|
||||
if got := ictWeekStartOf(refNow); !got.Equal(want) {
|
||||
t.Fatalf("week start = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
const subscribersKey = "subscribers"
|
||||
|
||||
// Subscriber is one chat/topic subscribed to the daily World Cup digest.
|
||||
type Subscriber struct {
|
||||
ChatID int64 `json:"chat_id" bson:"chat_id"`
|
||||
ThreadID int `json:"thread_id,omitempty" bson:"thread_id,omitempty"`
|
||||
}
|
||||
|
||||
type subscribersDoc struct {
|
||||
Subscribers []Subscriber `json:"subscribers" bson:"subscribers"`
|
||||
}
|
||||
|
||||
// SubscriberStore is the typed store for subscriber documents.
|
||||
type SubscriberStore = storage.DocStore[subscribersDoc]
|
||||
|
||||
func listSubscribers(ctx context.Context, store SubscriberStore) ([]Subscriber, error) {
|
||||
doc, _, err := store.Get(ctx, subscribersKey)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("wc listSubscribers: %w", err)
|
||||
}
|
||||
return doc.Subscribers, nil
|
||||
}
|
||||
|
||||
func addSubscriber(ctx context.Context, store SubscriberStore, chatID int64, threadID int) (bool, error) {
|
||||
subs, err := listSubscribers(ctx, store)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, s := range subs {
|
||||
if s.ChatID == chatID && s.ThreadID == threadID {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
subs = append(subs, Subscriber{ChatID: chatID, ThreadID: threadID})
|
||||
if err := store.Put(ctx, subscribersKey, subscribersDoc{Subscribers: subs}); err != nil {
|
||||
return false, fmt.Errorf("wc addSubscriber: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func removeSubscriber(ctx context.Context, store SubscriberStore, chatID int64, threadID int) (bool, error) {
|
||||
subs, err := listSubscribers(ctx, store)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
out := make([]Subscriber, 0, len(subs))
|
||||
removed := false
|
||||
for _, s := range subs {
|
||||
if s.ChatID == chatID && s.ThreadID == threadID {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if !removed {
|
||||
return false, nil
|
||||
}
|
||||
if err := store.Put(ctx, subscribersKey, subscribersDoc{Subscribers: out}); err != nil {
|
||||
return false, fmt.Errorf("wc removeSubscriber: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func removeAllForChat(ctx context.Context, store SubscriberStore, chatID int64) (int, error) {
|
||||
subs, err := listSubscribers(ctx, store)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
out := make([]Subscriber, 0, len(subs))
|
||||
removed := 0
|
||||
for _, s := range subs {
|
||||
if s.ChatID == chatID {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if removed == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if err := store.Put(ctx, subscribersKey, subscribersDoc{Subscribers: out}); err != nil {
|
||||
return 0, fmt.Errorf("wc removeAllForChat: %w", err)
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
func newSubscriberStore() SubscriberStore {
|
||||
return storage.Typed[subscribersDoc](storage.NewMemoryProvider().Collection("wc"))
|
||||
}
|
||||
|
||||
func TestSubscribers_AddRemoveAndTopics(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newSubscriberStore()
|
||||
|
||||
for _, tid := range []int{0, 5, 9} {
|
||||
added, err := addSubscriber(ctx, store, 100, tid)
|
||||
if err != nil || !added {
|
||||
t.Fatalf("add(100,%d): added=%v err=%v", tid, added, err)
|
||||
}
|
||||
}
|
||||
if added, _ := addSubscriber(ctx, store, 100, 5); added {
|
||||
t.Fatal("duplicate topic subscription should be no-op")
|
||||
}
|
||||
|
||||
if removed, _ := removeSubscriber(ctx, store, 100, 5); !removed {
|
||||
t.Fatal("remove(100,5) should remove")
|
||||
}
|
||||
subs, _ := listSubscribers(ctx, store)
|
||||
if len(subs) != 2 {
|
||||
t.Fatalf("subs = %v, want 2", subs)
|
||||
}
|
||||
for _, sub := range subs {
|
||||
if sub.ThreadID == 5 {
|
||||
t.Fatalf("removed topic still present: %v", subs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribers_RemoveAllForChat(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newSubscriberStore()
|
||||
for _, sub := range []Subscriber{{ChatID: 100}, {ChatID: 100, ThreadID: 9}, {ChatID: 200}} {
|
||||
if _, err := addSubscriber(ctx, store, sub.ChatID, sub.ThreadID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
n, err := removeAllForChat(ctx, store, 100)
|
||||
if err != nil || n != 2 {
|
||||
t.Fatalf("removeAllForChat = %d, %v; want 2, nil", n, err)
|
||||
}
|
||||
subs, _ := listSubscribers(ctx, store)
|
||||
if len(subs) != 1 || subs[0].ChatID != 200 {
|
||||
t.Fatalf("subs = %v, want only chat 200", subs)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type terminalKind int
|
||||
|
||||
const (
|
||||
terminalNone terminalKind = iota
|
||||
terminalChatWide
|
||||
terminalTopicOnly
|
||||
)
|
||||
|
||||
var chatWideTerminalMarkers = []string{
|
||||
"bot was blocked by the user",
|
||||
"user is deactivated",
|
||||
"bot is not a member",
|
||||
"chat not found",
|
||||
"group chat was upgraded",
|
||||
"chat was deleted",
|
||||
}
|
||||
|
||||
var topicOnlyTerminalMarkers = []string{
|
||||
"have no rights to send",
|
||||
}
|
||||
|
||||
func classifyTerminal(err error) terminalKind {
|
||||
if err == nil {
|
||||
return terminalNone
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, m := range chatWideTerminalMarkers {
|
||||
if strings.Contains(msg, m) {
|
||||
return terminalChatWide
|
||||
}
|
||||
}
|
||||
for _, m := range topicOnlyTerminalMarkers {
|
||||
if strings.Contains(msg, m) {
|
||||
return terminalTopicOnly
|
||||
}
|
||||
}
|
||||
return terminalNone
|
||||
}
|
||||
|
||||
var errNilBot = errors.New("wc daily push: deps.Bot is nil (BuildOptions.Bot not wired)")
|
||||
@@ -1,55 +0,0 @@
|
||||
package wc
|
||||
|
||||
import "github.com/tiennm99/miti99bot/internal/storage"
|
||||
|
||||
// Team is the football-data.org team object shape used by World Cup matches.
|
||||
type Team struct {
|
||||
ID int `json:"id,omitempty" bson:"id,omitempty"`
|
||||
Name string `json:"name,omitempty" bson:"name,omitempty"`
|
||||
ShortName string `json:"shortName,omitempty" bson:"shortName,omitempty"`
|
||||
TLA string `json:"tla,omitempty" bson:"tla,omitempty"`
|
||||
Crest string `json:"crest,omitempty" bson:"crest,omitempty"`
|
||||
}
|
||||
|
||||
// ScoreValue holds the home/away goals for one score phase. Pointers preserve
|
||||
// "not available yet" separately from a real 0-0 score.
|
||||
type ScoreValue struct {
|
||||
Home *int `json:"home,omitempty" bson:"home,omitempty"`
|
||||
Away *int `json:"away,omitempty" bson:"away,omitempty"`
|
||||
}
|
||||
|
||||
// Score is the subset of the football-data.org score payload that the bot
|
||||
// needs for live/finished display.
|
||||
type Score struct {
|
||||
Winner string `json:"winner,omitempty" bson:"winner,omitempty"`
|
||||
Duration string `json:"duration,omitempty" bson:"duration,omitempty"`
|
||||
FullTime ScoreValue `json:"fullTime,omitempty" bson:"fullTime,omitempty"`
|
||||
HalfTime ScoreValue `json:"halfTime,omitempty" bson:"halfTime,omitempty"`
|
||||
}
|
||||
|
||||
// Match is the normalized provider match persisted in the module cache.
|
||||
type Match struct {
|
||||
ID int `json:"id,omitempty" bson:"id,omitempty"`
|
||||
UTCDate string `json:"utcDate,omitempty" bson:"utcDate,omitempty"`
|
||||
Status string `json:"status,omitempty" bson:"status,omitempty"`
|
||||
Matchday int `json:"matchday,omitempty" bson:"matchday,omitempty"`
|
||||
Stage string `json:"stage,omitempty" bson:"stage,omitempty"`
|
||||
Group string `json:"group,omitempty" bson:"group,omitempty"`
|
||||
LastUpdated string `json:"lastUpdated,omitempty" bson:"lastUpdated,omitempty"`
|
||||
HomeTeam Team `json:"homeTeam,omitempty" bson:"homeTeam,omitempty"`
|
||||
AwayTeam Team `json:"awayTeam,omitempty" bson:"awayTeam,omitempty"`
|
||||
Score Score `json:"score,omitempty" bson:"score,omitempty"`
|
||||
Venue string `json:"venue,omitempty" bson:"venue,omitempty"`
|
||||
}
|
||||
|
||||
type matchesResponse struct {
|
||||
Matches []Match `json:"matches"`
|
||||
}
|
||||
|
||||
type cacheRecord struct {
|
||||
Ts int64 `json:"ts" bson:"ts"`
|
||||
Matches []Match `json:"matches" bson:"matches"`
|
||||
}
|
||||
|
||||
// CacheStore is the typed store for World Cup schedule cache records.
|
||||
type CacheStore = storage.DocStore[cacheRecord]
|
||||
@@ -1,45 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
// New is the World Cup schedule module factory.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
s := &state{
|
||||
subscribers: storage.Typed[subscribersDoc](deps.Store),
|
||||
pushDate: storage.Typed[lastPushDoc](deps.Store),
|
||||
cache: storage.Typed[cacheRecord](deps.Store),
|
||||
client: NewClientFromEnv(),
|
||||
}
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "wc",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "World Cup matches for a date (dd, dd-mm, dd/mm, ddmm, or full date; default today)",
|
||||
Handler: s.handleSchedule,
|
||||
},
|
||||
{
|
||||
Name: "wc_this_week",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "World Cup matches for this week (Mon-Sun, ICT)",
|
||||
Handler: s.handleWeek,
|
||||
},
|
||||
{
|
||||
Name: "wc_subscribe",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Get the daily World Cup schedule digest at 00:00 UTC+7",
|
||||
Handler: s.handleSubscribe,
|
||||
},
|
||||
{
|
||||
Name: "wc_unsubscribe",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Stop receiving the daily World Cup schedule digest",
|
||||
Handler: s.handleUnsubscribe,
|
||||
},
|
||||
},
|
||||
Crons: []modules.Cron{s.dailyPushCron()},
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package wc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
func TestNewRegistersExpectedCommandsAndCron(t *testing.T) {
|
||||
mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection("wc")})
|
||||
got := map[string]bool{}
|
||||
for _, cmd := range mod.Commands {
|
||||
got[cmd.Name] = true
|
||||
}
|
||||
for _, name := range []string{"wc", "wc_this_week", "wc_subscribe", "wc_unsubscribe"} {
|
||||
if !got[name] {
|
||||
t.Fatalf("missing command %s", name)
|
||||
}
|
||||
}
|
||||
if len(mod.Crons) != 1 || mod.Crons[0].Name != dailyPushCronName || mod.Crons[0].Schedule != "0 17 * * *" {
|
||||
t.Fatalf("crons = %+v, want %s at 00:00 UTC+7", mod.Crons, dailyPushCronName)
|
||||
}
|
||||
}
|
||||
@@ -84,22 +84,6 @@
|
||||
"command": "lol_unsubscribe",
|
||||
"description": "Stop the daily LoL schedule digest"
|
||||
},
|
||||
{
|
||||
"command": "wc",
|
||||
"description": "World Cup matches for a date; supports dd, dd-mm, dd/mm, ddmm"
|
||||
},
|
||||
{
|
||||
"command": "wc_this_week",
|
||||
"description": "World Cup matches for this week"
|
||||
},
|
||||
{
|
||||
"command": "wc_subscribe",
|
||||
"description": "Get the daily World Cup schedule digest"
|
||||
},
|
||||
{
|
||||
"command": "wc_unsubscribe",
|
||||
"description": "Stop the daily World Cup schedule digest"
|
||||
},
|
||||
{
|
||||
"command": "stock_price",
|
||||
"description": "Show current VN stock price"
|
||||
|
||||
Reference in New Issue
Block a user