feat: rename lol schedule module

This commit is contained in:
2026-07-01 10:38:49 +07:00
parent 6eeb7ef447
commit bdb8757dbe
22 changed files with 329 additions and 138 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ test: ## Unit tests (no emulator required)
MONGO_PORT ?= 27017
test-mongo: mongo-local ## Run MongoDB tests against a local Mongo container
MONGODB_TEST_URL=mongodb://127.0.0.1:$(MONGO_PORT) LOG_LEVEL=error \
go test -race -count=1 ./internal/storage/... ./internal/modules/stats/...
go test -race -count=1 ./internal/storage/... ./internal/modules/lol/... ./internal/modules/stats/...
# ---- Lint / Vet ------------------------------------------------------------
+1 -1
View File
@@ -11,7 +11,7 @@ Atlas via long polling and an in-process cron scheduler.
| `misc` | `/ping`, `/mstats`, `/trongtruonghop` disclaimer |
| `wordle` | Daily Wordle game |
| `loldle` | League-of-Legends "guess the champion" |
| `lolschedule` | Pro-match schedule + daily push |
| `lol` | Pro-match schedule + daily push |
| `wc` | World Cup schedule + silent daily push |
| `stock` | VN-stocks paper trading |
| `gold` | Gold paper trading (opt-in; primary VNAppMob SJC buy/sell VND/luong, fallback spot XAU) |
+15 -12
View File
@@ -19,8 +19,8 @@ import (
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/coin"
"github.com/tiennm99/miti99bot/internal/modules/gold"
"github.com/tiennm99/miti99bot/internal/modules/lol"
"github.com/tiennm99/miti99bot/internal/modules/loldle"
"github.com/tiennm99/miti99bot/internal/modules/lolschedule"
"github.com/tiennm99/miti99bot/internal/modules/misc"
"github.com/tiennm99/miti99bot/internal/modules/stats"
"github.com/tiennm99/miti99bot/internal/modules/stock"
@@ -58,16 +58,16 @@ func resolveCommitSHA(envSourceCommit string) string {
// import cycle (modules → util → modules).
func factories() map[string]modules.Factory {
return map[string]modules.Factory{
"util": util.New,
"misc": misc.New,
"wordle": wordle.New,
"loldle": loldle.New,
"lolschedule": lolschedule.New,
"wc": wc.New,
"coin": coin.New,
"gold": gold.New,
"stock": stock.New,
"stats": stats.New,
"util": util.New,
"misc": misc.New,
"wordle": wordle.New,
"loldle": loldle.New,
lol.CollectionName: lol.New,
"wc": wc.New,
"coin": coin.New,
"gold": gold.New,
"stock": stock.New,
"stats": stats.New,
}
}
@@ -96,6 +96,9 @@ func main() {
defer closeProvider()
systemColl := provider.Collection(systemstate.CollectionName)
if err := lol.InitStore(rootCtx, provider, systemColl); err != nil {
log.Fatal("lol storage init failed", "err", err)
}
if err := stats.InitStore(rootCtx, provider.Collection("stats"), systemColl); err != nil {
log.Fatal("stats storage init failed", "err", err)
}
@@ -124,7 +127,7 @@ func main() {
}
// In-process cron scheduler runs unconditionally so the long-lived container
// fires module crons (e.g. the lolschedule daily push) on their Schedule.
// fires module crons (e.g. lol daily push) on their Schedule.
stopCron, err := cron.Run(rootCtx, reg)
if err != nil {
log.Fatal("cron scheduler init failed", "err", err)
+6 -2
View File
@@ -65,10 +65,14 @@ overrides are not supported in runtime env; modules use coded defaults.
> 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: schedule subscribers under `subscribers` (array) and the daily
> push date under `date`. Concurrency uses the `version` field (optimistic lock);
> named field: `lol` schedule subscribers under `subscribers` (array) and the
> daily push date under `date`. Concurrency uses the `version` field (optimistic lock);
> `updatedAt` is a BSON Date.
>
> The `lol` module uses the `lol` collection. First startup after the rename
> copies documents from the legacy `lolschedule` collection, drops that legacy
> collection, and records completion in `system`.
>
> The `stats` collection uses queryable aggregate documents for command/user
> counts and creates indexes on startup. First startup after the schema change
> migrates legacy `count:`, `user:`, and `pair:` stats keys into the new shape,
@@ -1,4 +1,4 @@
// Package lolschedule serves LoL esports match schedules via lolesports.com's
// Package lol serves LoL esports match schedules via lolesports.com's
// persisted API plus a daily push to subscribers.
//
// Endpoint: https://esports-api.lolesports.com/persisted/gw/getSchedule
@@ -8,7 +8,7 @@
//
// Cache strategy: KV-backed cacheRecord with a 120s fresh window and a
// 60-minute stale fallback (stale-while-error).
package lolschedule
package lol
import (
"context"
@@ -153,7 +153,7 @@ func (c *Client) baseURL() string {
func (c *Client) fetchSchedulePage(ctx context.Context, pageToken string) ([]ScheduleEvent, string, string, error) {
u, err := url.Parse(c.baseURL())
if err != nil {
return nil, "", "", fmt.Errorf("lolschedule parse url: %w", err)
return nil, "", "", fmt.Errorf("lol parse url: %w", err)
}
q := u.Query()
q.Set("hl", "en-US")
@@ -164,7 +164,7 @@ func (c *Client) fetchSchedulePage(ctx context.Context, pageToken string) ([]Sch
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, "", "", fmt.Errorf("lolschedule build request: %w", err)
return nil, "", "", fmt.Errorf("lol build request: %w", err)
}
req.Header.Set("x-api-key", apiKey)
req.Header.Set("User-Agent", userAgent)
@@ -172,20 +172,20 @@ func (c *Client) fetchSchedulePage(ctx context.Context, pageToken string) ([]Sch
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, "", "", fmt.Errorf("lolschedule do: %w", err)
return nil, "", "", fmt.Errorf("lol do: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", "", fmt.Errorf("lolschedule read: %w", err)
return nil, "", "", fmt.Errorf("lol read: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Warn("lolschedule_fetch", "status", resp.StatusCode, "body", truncate(string(body), 500))
return nil, "", "", fmt.Errorf("lolschedule API HTTP %d", resp.StatusCode)
log.Warn("lol_fetch", "status", resp.StatusCode, "body", truncate(string(body), 500))
return nil, "", "", fmt.Errorf("lol API HTTP %d", resp.StatusCode)
}
var page schedulePage
if err := json.Unmarshal(body, &page); err != nil {
return nil, "", "", fmt.Errorf("lolschedule decode: %w", err)
return nil, "", "", fmt.Errorf("lol decode: %w", err)
}
// Drop pre/post-show segments; they aren't matches.
out := make([]ScheduleEvent, 0, len(page.Data.Schedule.Events))
@@ -301,14 +301,14 @@ func (c *Client) GetEventsCached(ctx context.Context, cache CacheStore, from, to
if fetchErr == nil {
rec := cacheRecord{Ts: now, Events: events}
if err := cache.Put(ctx, key, rec); err != nil {
log.Warn("lolschedule_kv_put_fail", "err", err)
log.Warn("lol_kv_put_fail", "err", err)
}
return events, nil
}
// Upstream failed — fall back to stale cache if recent enough.
if hasCached && cached.Events != nil && now-cached.Ts < staleMaxAge.Milliseconds() {
log.Warn("lolschedule_stale_fallback", "err", fetchErr)
log.Warn("lol_stale_fallback", "err", fetchErr)
return cached.Events, nil
}
return nil, fetchErr
@@ -333,4 +333,4 @@ func truncate(s string, maxLen int) string {
// ErrEmptyResult is reserved for explicit "no events" scenarios where the
// fetch succeeded but returned zero matches. Currently unused outside tests
// but kept exported so callers can distinguish from network errors.
var ErrEmptyResult = errors.New("lolschedule: no events in range")
var ErrEmptyResult = errors.New("lol: no events in range")
@@ -1,4 +1,4 @@
package lolschedule
package lol
import (
"context"
@@ -16,7 +16,7 @@ import (
// newCacheStore returns a fresh typed cache store backed by an in-memory
// collection — the per-test equivalent of what the factory wires in production.
func newCacheStore() CacheStore {
return storage.Typed[cacheRecord](storage.NewMemoryProvider().Collection("lolschedule"))
return storage.Typed[cacheRecord](storage.NewMemoryProvider().Collection("lol"))
}
// mkServer spins an httptest.Server returning the supplied JSON body for
@@ -1,4 +1,4 @@
package lolschedule
package lol
import (
"context"
@@ -79,7 +79,7 @@ func classifyTerminal(err error) terminalKind {
// dailyPushCronName is the cron route segment + in-process scheduler key.
// Must match the regex in internal/server/router.go (^[a-z0-9_]{1,32}$).
const dailyPushCronName = "lolschedule_daily_push"
const dailyPushCronName = "lol_daily_push"
// dailyPushSchedule drives the in-process scheduler (internal/cron). Cron
// expression is UTC; 01:00 UTC == 08:00 ICT.
@@ -130,7 +130,7 @@ func (s *state) dailyPushCron() modules.Cron {
// core logic is testable without an actual *bot.Bot.
func (s *state) dailyPushHandler(ctx context.Context, deps modules.Deps) error {
if deps.Bot == nil {
return errors.New("lolschedule daily push: deps.Bot is nil (BuildOptions.Bot not wired)")
return errors.New("lol daily push: deps.Bot is nil (BuildOptions.Bot not wired)")
}
return runDailyPush(ctx, s, deps.Bot)
}
@@ -174,10 +174,10 @@ func claimDailyPush(ctx context.Context, store PushDateStore, pushDay string) (b
func runDailyPush(ctx context.Context, s *state, sender messageSender) error {
subs, err := listSubscribers(ctx, s.subscribers)
if err != nil {
return fmt.Errorf("lolschedule daily push: list subscribers: %w", err)
return fmt.Errorf("lol daily push: list subscribers: %w", err)
}
if len(subs) == 0 {
log.Info("lolschedule daily push: no subscribers, skipping")
log.Info("lol daily push: no subscribers, skipping")
return nil
}
@@ -185,7 +185,7 @@ func runDailyPush(ctx context.Context, s *state, sender messageSender) error {
to := addDays(from, 1)
events, err := s.client.GetEventsCached(ctx, s.cache, from, to)
if err != nil {
return fmt.Errorf("lolschedule daily push: fetch matches: %w", err)
return fmt.Errorf("lol daily push: fetch matches: %w", err)
}
filtered := FilterMajor(events)
text := RenderToday(filtered, from)
@@ -198,10 +198,10 @@ func runDailyPush(ctx context.Context, s *state, sender messageSender) error {
pushDay := ictDayKey(from)
won, err := claimDailyPush(ctx, s.pushDate, pushDay)
if err != nil {
return fmt.Errorf("lolschedule daily push: claim date: %w", err)
return fmt.Errorf("lol daily push: claim date: %w", err)
}
if !won {
log.Info("lolschedule daily push: already pushed today, skipping", "date", pushDay)
log.Info("lol daily push: already pushed today, skipping", "date", pushDay)
return nil
}
@@ -224,7 +224,7 @@ func runDailyPush(ctx context.Context, s *state, sender messageSender) error {
ParseMode: models.ParseModeHTML,
DisableNotification: disableNotification,
}); err != nil {
log.Warn("lolschedule daily push send failed",
log.Warn("lol daily push send failed",
"chat", sub.ChatID, "thread", sub.ThreadID, "err", err)
failed++
switch classifyTerminal(err) {
@@ -243,7 +243,7 @@ func runDailyPush(ctx context.Context, s *state, sender messageSender) error {
// strictly an improvement even when the writes fail.
pruned := pruneDeadSubscribers(ctx, s, deadChats, deadTopics)
log.Info("lolschedule daily push complete",
log.Info("lol daily push complete",
"subscribers", len(subs),
"sent", sent,
"failed", failed,
@@ -266,7 +266,7 @@ func pruneDeadSubscribers(ctx context.Context, s *state, chatWide map[int64]stru
for chatID := range chatWide {
n, err := removeAllForChat(ctx, s.subscribers, chatID)
if err != nil {
log.Warn("lolschedule prune dead chat failed", "chat", chatID, "err", err)
log.Warn("lol prune dead chat failed", "chat", chatID, "err", err)
continue
}
removed += n
@@ -279,7 +279,7 @@ func pruneDeadSubscribers(ctx context.Context, s *state, chatWide map[int64]stru
}
ok, err := removeSubscriber(ctx, s.subscribers, sub.ChatID, sub.ThreadID)
if err != nil {
log.Warn("lolschedule prune dead topic failed",
log.Warn("lol prune dead topic failed",
"chat", sub.ChatID, "thread", sub.ThreadID, "err", err)
continue
}
@@ -1,4 +1,4 @@
package lolschedule
package lol
import (
"context"
@@ -60,7 +60,7 @@ func fixedNow() time.Time {
// collection, matching what the factory wires in production.
func newTestStore(t *testing.T) (SubscriberStore, PushDateStore, CacheStore) {
t.Helper()
col := storage.NewMemoryProvider().Collection("lolschedule")
col := storage.NewMemoryProvider().Collection("lol")
return storage.Typed[subscribersDoc](col),
storage.Typed[lastPushDoc](col),
storage.Typed[cacheRecord](col)
@@ -434,7 +434,7 @@ func TestClassifyTerminal(t *testing.T) {
func TestDailyPushHandler_NilBot_ReturnsError(t *testing.T) {
s := newTestState(t)
deps := modules.Deps{Store: storage.NewMemoryProvider().Collection("lolschedule")}
deps := modules.Deps{Store: storage.NewMemoryProvider().Collection("lol")}
err := s.dailyPushHandler(context.Background(), deps)
if err == nil {
t.Fatal("expected error when deps.Bot is nil, got nil")
@@ -1,4 +1,4 @@
package lolschedule
package lol
import (
"fmt"
@@ -27,15 +27,15 @@ var leagueOrder = []string{
// tournaments most viewers care about. Without this filter the API
// returns 135+ events/week and replies blow past Telegram's 4096-char limit.
var majorLeagueSlugs = map[string]bool{
"lck": true,
"lpl": true,
"lec": true,
"lcs": true,
"worlds": true,
"msi": true,
"first_stand": true,
"lcp": true,
"cblol-brazil": true,
"lck": true,
"lpl": true,
"lec": true,
"lcs": true,
"worlds": true,
"msi": true,
"first_stand": true,
"lcp": true,
"cblol-brazil": true,
"emea_masters": true,
}
@@ -1,4 +1,4 @@
package lolschedule
package lol
import (
"strings"
@@ -1,4 +1,4 @@
package lolschedule
package lol
import (
"context"
@@ -12,7 +12,7 @@ import (
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
)
// state captures everything a lolschedule handler needs at runtime.
// state captures everything a lol handler needs at runtime.
type state struct {
subscribers SubscriberStore
pushDate PushDateStore
@@ -22,7 +22,7 @@ type state struct {
// uses time.Now via the default zero-value.
nowFn func() time.Time
// subscribersMu serializes Get→mutate→Put on the single subscribers store
// slot. Two concurrent /lolschedule_subscribe calls in the same
// slot. Two concurrent /lol_subscribe calls in the same
// millisecond would otherwise race and drop one append.
subscribersMu sync.Mutex
}
@@ -34,7 +34,7 @@ func (s *state) now() time.Time {
return time.Now()
}
// handleSchedule is /lolschedule [date] — matches for one ICT day.
// handleSchedule is /lol [date] — matches for one ICT day.
// Empty arg → today.
func (s *state) handleSchedule(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
@@ -49,17 +49,7 @@ func (s *state) handleSchedule(ctx context.Context, b *bot.Bot, update *models.U
return s.replyForRange(ctx, b, msg, parsed.Date, addDays(parsed.Date, 1), false)
}
// handleToday is /lolschedule_today — today's matches.
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)
}
// handleWeek is /lolschedule_week — the current ICT calendar week
// handleWeek is /lol_this_week — the current ICT calendar week
// (Monday 00:00 ICT through the following Monday 00:00 ICT, exclusive).
func (s *state) handleWeek(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
@@ -75,7 +65,7 @@ func (s *state) handleWeek(ctx context.Context, b *bot.Bot, update *models.Updat
func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Message, from, to time.Time, week bool) error {
events, err := s.client.GetEventsCached(ctx, s.cache, from, to)
if err != nil {
log.Error("lolschedule_fetch_fail", "err", err, "from", from, "to", to)
log.Error("lol_fetch_fail", "err", err, "from", from, "to", to)
hint := "Could not fetch matches. Try again later."
if week {
hint = "Could not fetch this week's matches. Try again later."
@@ -92,7 +82,7 @@ func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Messa
return chathelper.ReplyHTML(ctx, b, msg, text)
}
// handleSubscribe is /lolschedule_subscribe — opt the chat into the daily
// handleSubscribe is /lol_subscribe — opt the chat into the daily
// digest delivered by the in-process cron handler.
func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
@@ -113,7 +103,7 @@ func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.
return chathelper.Reply(ctx, b, msg, "Already subscribed.")
}
// handleUnsubscribe is /lolschedule_unsubscribe — opt out.
// handleUnsubscribe is /lol_unsubscribe — opt out.
func (s *state) handleUnsubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
@@ -1,4 +1,4 @@
package lolschedule
package lol
import (
"context"
@@ -13,7 +13,7 @@ import (
"github.com/tiennm99/miti99bot/internal/testutil"
)
// installSchedule wires the lolschedule module to a recording bot, with a
// installSchedule wires the lol module to a recording bot, with a
// custom upstream HTTP server returning bodyJSON for every request. nowMs
// fixes the clock so date-based handlers are deterministic.
// Returns the recording bot and the subscriber store for inspection in tests.
@@ -26,7 +26,7 @@ func installSchedule(t *testing.T, bodyJSON string, nowMs int64) (*testutil.Reco
t.Cleanup(upstream.Close)
rb := testutil.NewRecordingBot(t)
col := storage.NewMemoryProvider().Collection("lolschedule")
col := storage.NewMemoryProvider().Collection("lol")
s := &state{
subscribers: storage.Typed[subscribersDoc](col),
@@ -36,13 +36,12 @@ func installSchedule(t *testing.T, bodyJSON string, nowMs int64) (*testutil.Reco
nowFn: func() time.Time { return time.UnixMilli(nowMs).UTC() },
}
mod := modules.Module{
Name: "lolschedule",
Name: "lol",
Commands: []modules.Command{
{Name: "lolschedule", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule},
{Name: "lolschedule_today", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleToday},
{Name: "lolschedule_week", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleWeek},
{Name: "lolschedule_subscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSubscribe},
{Name: "lolschedule_unsubscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleUnsubscribe},
{Name: "lol", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule},
{Name: "lol_this_week", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleWeek},
{Name: "lol_subscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSubscribe},
{Name: "lol_unsubscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleUnsubscribe},
},
}
reg := &modules.Registry{
@@ -76,9 +75,9 @@ const todayBody = `{
}
}`
func TestHandleToday_RendersHTMLAndFiltersMajor(t *testing.T) {
func TestHandleSchedule_DefaultsToToday(t *testing.T) {
rb, _ := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lolschedule_today"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol"))
got := rb.LastSent()
if got.Method != "sendMessage" {
@@ -96,7 +95,7 @@ func TestHandleToday_RendersHTMLAndFiltersMajor(t *testing.T) {
func TestHandleSchedule_BadDateInput(t *testing.T) {
rb, _ := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lolschedule notadate"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol notadate"))
got := rb.LastSent().Text()
if !strings.Contains(got, "Invalid date") {
@@ -106,7 +105,7 @@ func TestHandleSchedule_BadDateInput(t *testing.T) {
func TestHandleWeek_RendersWeek(t *testing.T) {
rb, _ := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lolschedule_week"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol_this_week"))
got := rb.LastSent().Text()
if !strings.Contains(got, "→") {
@@ -122,12 +121,12 @@ func TestHandleWeek_RendersWeek(t *testing.T) {
func TestHandleSubscribe_AddsAndIsIdempotent(t *testing.T) {
rb, subsStore := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lolschedule_subscribe"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_subscribe"))
if got := rb.LastSent().Text(); !strings.HasPrefix(got, "✅") {
t.Errorf("first subscribe should confirm; got %q", got)
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lolschedule_subscribe"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_subscribe"))
if got := rb.LastSent().Text(); !strings.Contains(got, "Already subscribed") {
t.Errorf("duplicate subscribe should report Already; got %q", got)
}
@@ -143,7 +142,7 @@ func TestHandleSubscribe_AddsAndIsIdempotent(t *testing.T) {
// originating topic instead of General.
func TestHandleSubscribe_ForumTopic_CapturesThreadID(t *testing.T) {
rb, subsStore := installSchedule(t, todayBody, fakeNowMs)
upd := testutil.NewSupergroupMessage(555, 999, "/lolschedule_subscribe")
upd := testutil.NewSupergroupMessage(555, 999, "/lol_subscribe")
upd.Message.MessageThreadID = 42
rb.Bot.ProcessUpdate(context.Background(), upd)
@@ -162,13 +161,13 @@ func TestHandleUnsubscribe_ForumTopic_RemovesOnlyThatTopic(t *testing.T) {
// Subscribe in two topics of the same supergroup.
for _, tid := range []int{42, 99} {
upd := testutil.NewSupergroupMessage(555, 999, "/lolschedule_subscribe")
upd := testutil.NewSupergroupMessage(555, 999, "/lol_subscribe")
upd.Message.MessageThreadID = tid
rb.Bot.ProcessUpdate(context.Background(), upd)
}
// Unsubscribe in topic 42 only.
upd := testutil.NewSupergroupMessage(555, 999, "/lolschedule_unsubscribe")
upd := testutil.NewSupergroupMessage(555, 999, "/lol_unsubscribe")
upd.Message.MessageThreadID = 42
rb.Bot.ProcessUpdate(context.Background(), upd)
@@ -181,14 +180,14 @@ func TestHandleUnsubscribe_ForumTopic_RemovesOnlyThatTopic(t *testing.T) {
func TestHandleUnsubscribe(t *testing.T) {
rb, _ := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lolschedule_subscribe"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_subscribe"))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lolschedule_unsubscribe"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_unsubscribe"))
if got := rb.LastSent().Text(); got != "Unsubscribed." {
t.Errorf("unsubscribe reply = %q, want 'Unsubscribed.'", got)
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lolschedule_unsubscribe"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_unsubscribe"))
if got := rb.LastSent().Text(); !strings.Contains(got, "weren't subscribed") {
t.Errorf("idempotent unsubscribe reply = %q", got)
}
@@ -201,7 +200,7 @@ func TestHandleSchedule_UpstreamFailureGivesFriendlyError(t *testing.T) {
defer upstream.Close()
rb := testutil.NewRecordingBot(t)
col := storage.NewMemoryProvider().Collection("lolschedule")
col := storage.NewMemoryProvider().Collection("lol")
s := &state{
subscribers: storage.Typed[subscribersDoc](col),
pushDate: storage.Typed[lastPushDoc](col),
@@ -209,14 +208,14 @@ func TestHandleSchedule_UpstreamFailureGivesFriendlyError(t *testing.T) {
client: &Client{HTTP: upstream.Client(), URL: upstream.URL},
nowFn: func() time.Time { return time.UnixMilli(fakeNowMs).UTC() },
}
cmd := modules.Command{Name: "lolschedule_today", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleToday}
cmd := modules.Command{Name: "lol", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule}
reg := &modules.Registry{
Modules: []modules.Module{{Name: "lolschedule", Commands: []modules.Command{cmd}}},
Modules: []modules.Module{{Name: "lol", 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, "/lolschedule_today"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol"))
if got := rb.LastSent().Text(); !strings.Contains(got, "Could not fetch") {
t.Errorf("expected friendly fetch-error reply; got %q", got)
}
@@ -1,12 +1,12 @@
package lolschedule
package lol
import (
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
)
// New is the lolschedule module Factory. The 5 user-facing commands plus the
// daily-push cron (lolschedule_daily_push at 08:00 ICT, fan-out to
// New is the lol module Factory. The 4 user-facing commands plus the
// daily-push cron (lol_daily_push at 08:00 ICT, fan-out to
// subscribers) are wired here. The cron handler reads deps.Bot at invoke
// time — main.go must wire BuildOptions.Bot for the cron to function;
// without it the handler fails fast with a clear error.
@@ -20,31 +20,25 @@ func New(deps modules.Deps) modules.Module {
return modules.Module{
Commands: []modules.Command{
{
Name: "lolschedule",
Name: "lol",
Visibility: modules.VisibilityPublic,
Description: "LoL matches for a date (dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy; default today)",
Handler: s.handleSchedule,
},
{
Name: "lolschedule_today",
Visibility: modules.VisibilityPublic,
Description: "Today's LoL esports matches (scores if played)",
Handler: s.handleToday,
},
{
Name: "lolschedule_week",
Name: "lol_this_week",
Visibility: modules.VisibilityPublic,
Description: "LoL esports matches for this week (MonSun, ICT)",
Handler: s.handleWeek,
},
{
Name: "lolschedule_subscribe",
Name: "lol_subscribe",
Visibility: modules.VisibilityPublic,
Description: "Get the daily LoL schedule digest at 08:00 ICT",
Handler: s.handleSubscribe,
},
{
Name: "lolschedule_unsubscribe",
Name: "lol_unsubscribe",
Visibility: modules.VisibilityPublic,
Description: "Stop receiving the daily LoL schedule digest",
Handler: s.handleUnsubscribe,
@@ -1,4 +1,4 @@
package lolschedule
package lol
import (
"fmt"
@@ -86,7 +86,7 @@ func splitParts(trimmed string) ([]string, string) {
}
}
// ParseScheduleDate parses a /lolschedule date argument. Empty input → today.
// ParseScheduleDate parses a /lol date argument. Empty input → today.
// Returns the start of the requested ICT day as a UTC instant.
func ParseScheduleDate(input string, now time.Time) parseDateResult {
trimmed := strings.TrimSpace(input)
@@ -1,4 +1,4 @@
package lolschedule
package lol
import (
"strings"
+97
View File
@@ -0,0 +1,97 @@
package lol
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/systemstate"
)
const (
// CollectionName is the current MongoDB collection/module key.
CollectionName = "lol"
legacyCollectionName = "lolschedule"
collectionMigrationName = "lol-collection-v1"
collectionMigrationKey = "migration:" + collectionMigrationName
)
// InitStore performs lol collection startup maintenance. It is safe to call on
// every boot: the collection rename migration is guarded by the system
// collection and only applies to Mongo-backed storage.
func InitStore(ctx context.Context, provider storage.Provider, systemColl storage.Collection) error {
oldColl, okOld := storage.MongoCollection(provider.Collection(legacyCollectionName))
newColl, okNew := storage.MongoCollection(provider.Collection(CollectionName))
if !okOld || !okNew {
return nil
}
sys := systemstate.New(systemColl)
if rec, ok, err := sys.Get(ctx, collectionMigrationKey); err != nil {
return fmt.Errorf("lol collection migration marker get: %w", err)
} else if ok && rec.Status == "done" {
return nil
}
count, err := oldColl.CountDocuments(ctx, bson.D{})
if err != nil {
return fmt.Errorf("lol legacy collection count: %w", err)
}
if count == 0 {
if err := oldColl.Drop(ctx); err != nil {
return fmt.Errorf("lol legacy collection drop: %w", err)
}
return markCollectionMigrationDone(ctx, sys, 0)
}
cur, err := oldColl.Find(ctx, bson.D{})
if err != nil {
return fmt.Errorf("lol legacy collection find: %w", err)
}
defer func() { _ = cur.Close(ctx) }()
copied := int64(0)
for cur.Next(ctx) {
var doc bson.M
if err := cur.Decode(&doc); err != nil {
return fmt.Errorf("lol legacy collection decode: %w", err)
}
id, ok := doc["_id"]
if !ok {
return fmt.Errorf("lol legacy collection document missing _id")
}
if _, err := newColl.ReplaceOne(ctx, bson.M{"_id": id}, doc, options.Replace().SetUpsert(true)); err != nil {
return fmt.Errorf("lol collection copy %v: %w", id, err)
}
copied++
}
if err := cur.Err(); err != nil {
return fmt.Errorf("lol legacy collection cursor: %w", err)
}
if err := oldColl.Drop(ctx); err != nil {
return fmt.Errorf("lol legacy collection drop: %w", err)
}
return markCollectionMigrationDone(ctx, sys, copied)
}
func markCollectionMigrationDone(ctx context.Context, sys systemstate.Store, count int64) error {
now := time.Now().UnixMilli()
if err := sys.Put(ctx, collectionMigrationKey, systemstate.Record{
Kind: "migration",
Name: collectionMigrationName,
Status: "done",
Count: count,
CompletedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("lol collection migration marker put: %w", err)
}
return nil
}
+108
View File
@@ -0,0 +1,108 @@
package lol
import (
"context"
"fmt"
"os"
"testing"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/systemstate"
)
func TestInitStore_MongoMigratesLegacyCollection(t *testing.T) {
uri := os.Getenv("MONGODB_TEST_URL")
if uri == "" {
t.Skip("MONGODB_TEST_URL not set; skipping MongoDB integration test")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := storage.NewMongoClient(ctx, uri)
if err != nil {
t.Fatalf("NewMongoClient: %v", err)
}
dbName := fmt.Sprintf("miti99bot_lol_test_%d", time.Now().UnixNano())
db := client.Database(dbName)
defer func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = db.Drop(cleanupCtx)
_ = client.Disconnect(cleanupCtx)
}()
provider := storage.NewMongoProvider(db)
legacyColl := provider.Collection(legacyCollectionName)
systemColl := provider.Collection(systemstate.CollectionName)
legacySubscribers := storage.Typed[subscribersDoc](legacyColl)
legacyPushDate := storage.Typed[lastPushDoc](legacyColl)
legacyCache := storage.Typed[cacheRecord](legacyColl)
if err := legacySubscribers.Put(ctx, subscribersKey, subscribersDoc{Subscribers: []Subscriber{{ChatID: 7}, {ChatID: 8, ThreadID: 3}}}); err != nil {
t.Fatalf("legacy subscribers: %v", err)
}
if err := legacyPushDate.Put(ctx, lastPushDateKey, lastPushDoc{Date: "2026-07-01"}); err != nil {
t.Fatalf("legacy push date: %v", err)
}
if err := legacyCache.Put(ctx, "matches:from:to", cacheRecord{
Ts: 123,
Events: []ScheduleEvent{{StartTime: "2026-07-01T01:00:00Z", League: League{Slug: "lck"}}},
}); err != nil {
t.Fatalf("legacy cache: %v", err)
}
if err := InitStore(ctx, provider, systemColl); err != nil {
t.Fatalf("InitStore: %v", err)
}
newColl := provider.Collection(CollectionName)
newSubscribers := storage.Typed[subscribersDoc](newColl)
newPushDate := storage.Typed[lastPushDoc](newColl)
newCache := storage.Typed[cacheRecord](newColl)
subs, _, err := newSubscribers.Get(ctx, subscribersKey)
if err != nil {
t.Fatalf("new subscribers get: %v", err)
}
if len(subs.Subscribers) != 2 || subs.Subscribers[1] != (Subscriber{ChatID: 8, ThreadID: 3}) {
t.Fatalf("new subscribers = %+v", subs.Subscribers)
}
push, _, err := newPushDate.Get(ctx, lastPushDateKey)
if err != nil {
t.Fatalf("new push date get: %v", err)
}
if push.Date != "2026-07-01" {
t.Fatalf("new push date = %q", push.Date)
}
cached, _, err := newCache.Get(ctx, "matches:from:to")
if err != nil {
t.Fatalf("new cache get: %v", err)
}
if cached.Ts != 123 || len(cached.Events) != 1 || cached.Events[0].League.Slug != "lck" {
t.Fatalf("new cache = %+v", cached)
}
names, err := db.ListCollectionNames(ctx, bson.M{"name": legacyCollectionName})
if err != nil {
t.Fatalf("ListCollectionNames: %v", err)
}
if len(names) != 0 {
t.Fatalf("legacy collection still exists: %v", names)
}
rec, ok, err := systemstate.New(systemColl).Get(ctx, collectionMigrationKey)
if err != nil {
t.Fatalf("migration marker get: %v", err)
}
if !ok || rec.Status != "done" || rec.Name != collectionMigrationName || rec.Count != 3 {
t.Fatalf("migration marker = %+v ok=%v", rec, ok)
}
if err := InitStore(ctx, provider, systemColl); err != nil {
t.Fatalf("second InitStore: %v", err)
}
}
@@ -1,4 +1,4 @@
package lolschedule
package lol
import (
"context"
@@ -51,7 +51,7 @@ func listSubscribers(ctx context.Context, store SubscriberStore) ([]Subscriber,
case errors.Is(err, storage.ErrNotFound):
return nil, nil
case err != nil:
return nil, fmt.Errorf("lolschedule listSubscribers: %w", err)
return nil, fmt.Errorf("lol listSubscribers: %w", err)
}
if doc.Subscribers != nil {
return doc.Subscribers, nil
@@ -69,7 +69,7 @@ func listSubscribersLegacy(raw []byte) ([]Subscriber, error) {
}
var legacy []int64
if err := json.Unmarshal(raw, &legacy); err != nil {
return nil, fmt.Errorf("lolschedule listSubscribers decode: %w", err)
return nil, fmt.Errorf("lol listSubscribers decode: %w", err)
}
out := make([]Subscriber, len(legacy))
for i, id := range legacy {
@@ -97,7 +97,7 @@ func addSubscriber(ctx context.Context, store SubscriberStore, chatID int64, thr
}
subs = append(subs, Subscriber{ChatID: chatID, ThreadID: threadID})
if err := store.Put(ctx, subscribersKey, subscribersDoc{Subscribers: subs}); err != nil {
return false, fmt.Errorf("lolschedule addSubscriber: %w", err)
return false, fmt.Errorf("lol addSubscriber: %w", err)
}
return true, nil
}
@@ -125,7 +125,7 @@ func removeSubscriber(ctx context.Context, store SubscriberStore, chatID int64,
return false, nil
}
if err := store.Put(ctx, subscribersKey, subscribersDoc{Subscribers: out}); err != nil {
return false, fmt.Errorf("lolschedule removeSubscriber: %w", err)
return false, fmt.Errorf("lol removeSubscriber: %w", err)
}
return true, nil
}
@@ -155,7 +155,7 @@ func removeAllForChat(ctx context.Context, store SubscriberStore, chatID int64)
return 0, nil
}
if err := store.Put(ctx, subscribersKey, subscribersDoc{Subscribers: out}); err != nil {
return 0, fmt.Errorf("lolschedule removeAllForChat: %w", err)
return 0, fmt.Errorf("lol removeAllForChat: %w", err)
}
return removed, nil
}
@@ -1,4 +1,4 @@
package lolschedule
package lol
import (
"context"
@@ -9,7 +9,7 @@ import (
)
func newSubscriberStore() SubscriberStore {
return storage.Typed[subscribersDoc](storage.NewMemoryProvider().Collection("lolschedule"))
return storage.Typed[subscribersDoc](storage.NewMemoryProvider().Collection("lol"))
}
func TestSubscribers_AddRemoveListIdempotent(t *testing.T) {
+1 -1
View File
@@ -72,7 +72,7 @@ type Module struct {
type Deps struct {
Store storage.Collection // the module's own collection; build typed views with storage.Typed[T]
Registry *Registry // populated by Build; safe to capture but read-only at module use
Bot *bot.Bot // nil-safe: only crons that fan-out (lolschedule daily push) need it
Bot *bot.Bot // nil-safe: only crons that fan-out (lol daily push) need it
}
// Factory constructs a Module from its Deps. Deps are passed directly (instead
+5 -5
View File
@@ -162,7 +162,7 @@ func TestMongoDocStore_PutVersionedStaleConflict(t *testing.T) {
}
// wrappedScalar / wrappedArray prove non-object values become named root fields
// (the lolschedule pattern) rather than an envelope or fallback.
// (the lol pattern) rather than an envelope or fallback.
type wrappedScalar struct {
Date string `json:"date" bson:"date"`
}
@@ -176,20 +176,20 @@ func TestMongoDocStore_WrappedScalarAndArray(t *testing.T) {
ctx := context.Background()
p := NewMongoProvider(db)
scalar := Typed[wrappedScalar](p.Collection("lolschedule"))
scalar := Typed[wrappedScalar](p.Collection("lol"))
if err := scalar.Put(ctx, "last_push_date", wrappedScalar{Date: "2026-06-28"}); err != nil {
t.Fatalf("scalar Put: %v", err)
}
doc := rawDoc(t, db.Collection("lolschedule"), "last_push_date")
doc := rawDoc(t, db.Collection("lol"), "last_push_date")
if doc["date"] != "2026-06-28" {
t.Errorf("scalar root field date = %v", doc["date"])
}
arr := Typed[wrappedArray](p.Collection("lolschedule"))
arr := Typed[wrappedArray](p.Collection("lol"))
if err := arr.Put(ctx, "subscribers", wrappedArray{Subscribers: []int{1, 2, 3}}); err != nil {
t.Fatalf("array Put: %v", err)
}
doc = rawDoc(t, db.Collection("lolschedule"), "subscribers")
doc = rawDoc(t, db.Collection("lol"), "subscribers")
if _, ok := doc["subscribers"].(bson.A); !ok {
t.Errorf("array root field subscribers = %T, want bson.A", doc["subscribers"])
}
+5 -9
View File
@@ -41,23 +41,19 @@
"description": "Show your loldle stats"
},
{
"command": "lolschedule",
"command": "lol",
"description": "LoL matches for a date"
},
{
"command": "lolschedule_today",
"description": "Today's LoL esports matches"
"command": "lol_this_week",
"description": "LoL matches for this week"
},
{
"command": "lolschedule_week",
"description": "LoL matches for the next 7 days"
},
{
"command": "lolschedule_subscribe",
"command": "lol_subscribe",
"description": "Get the daily LoL schedule digest"
},
{
"command": "lolschedule_unsubscribe",
"command": "lol_unsubscribe",
"description": "Stop the daily LoL schedule digest"
},
{