From 3e4e0e5b6e79233d4c8019681982a1b659332f0b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 21 Apr 2026 10:23:17 +0700 Subject: [PATCH] feat(lolschedule): per-chat subscribe/unsubscribe for the daily cron Replaces the single LOLSCHEDULE_CHAT_ID env var with a KV-backed subscriber list. New commands /lolschedule_subscribe and /lolschedule_unsubscribe let each chat opt in/out. The cron now fans out to every subscriber via Promise.allSettled so one blocked chat cannot break the others. --- src/modules/lolschedule/README.md | 6 +- src/modules/lolschedule/handlers.js | 84 +++++++++--- src/modules/lolschedule/index.js | 24 +++- src/modules/lolschedule/subscribers.js | 41 ++++++ tests/modules/lolschedule/handlers.test.js | 121 ++++++++++++++---- tests/modules/lolschedule/subscribers.test.js | 43 +++++++ wrangler.toml | 5 - 7 files changed, 270 insertions(+), 54 deletions(-) create mode 100644 src/modules/lolschedule/subscribers.js create mode 100644 tests/modules/lolschedule/subscribers.test.js diff --git a/src/modules/lolschedule/README.md b/src/modules/lolschedule/README.md index 9cabfed..d487a4a 100644 --- a/src/modules/lolschedule/README.md +++ b/src/modules/lolschedule/README.md @@ -8,14 +8,16 @@ LoL esports match schedule via the **lolesports.com** esports-api (the data feed |---|---| | `/lolschedule_today` | Today's matches (ICT), grouped by league. Scores for played + live, times for upcoming. | | `/lolschedule_week` | Next 7 days, grouped by day → league. | +| `/lolschedule_subscribe` | Opt the current chat into the daily 08:00 ICT digest. | +| `/lolschedule_unsubscribe` | Stop receiving the digest. | ## Cron | Schedule (UTC) | Local (ICT) | Purpose | |---|---|---| -| `0 1 * * *` | 08:00 | Push today's major-league schedule to `LOLSCHEDULE_CHAT_ID`. Skipped silently when unset or when there are no matches today. | +| `0 1 * * *` | 08:00 | Fan today's major-league schedule out to every chat subscribed via `/lolschedule_subscribe`. Skipped when no subscribers, no token, or no matches today. | -Set the chat id with `wrangler secret put LOLSCHEDULE_CHAT_ID` (recommended) or as a `[vars]` entry in `wrangler.toml`. +Subscribers are stored under the module's `subscribers` KV key as a JSON array of chat ids. Per-chat failures during fan-out are logged and swallowed so a single blocked chat can't stop the others. ## Data source diff --git a/src/modules/lolschedule/handlers.js b/src/modules/lolschedule/handlers.js index 2140c56..d4577a1 100644 --- a/src/modules/lolschedule/handlers.js +++ b/src/modules/lolschedule/handlers.js @@ -9,6 +9,7 @@ import { getEventsCached } from "./api-client.js"; import { renderToday, renderWeek } from "./format.js"; +import { addSubscriber, listSubscribers, removeSubscriber } from "./subscribers.js"; const ICT_OFFSET_MS = 7 * 60 * 60 * 1000; @@ -110,37 +111,84 @@ async function sendTelegramMessage(token, chatId, text) { } /** - * Cron handler — pushes today's major-league schedule to `LOLSCHEDULE_CHAT_ID`. - * Silently skips when the env var or token is missing, or when there are no - * major-league matches today. + * /lolschedule_subscribe — opts the current chat into the daily push. + * @param {import("grammy").Context} ctx + * @param {import("../../db/kv-store-interface.js").KVStore | null} db + */ +export async function handleSubscribe(ctx, db) { + if (!db) { + await ctx.reply("lolschedule: storage unavailable"); + return; + } + const chatId = ctx.chat?.id; + if (chatId == null) { + await ctx.reply("Could not read chat id — subscribe failed."); + return; + } + const added = await addSubscriber(db, chatId); + await ctx.reply( + added ? "✅ Subscribed. You'll get today's LoL schedule at 08:00 ICT." : "Already subscribed.", + ); +} + +/** + * /lolschedule_unsubscribe — removes the current chat from the daily push. + * @param {import("grammy").Context} ctx + * @param {import("../../db/kv-store-interface.js").KVStore | null} db + */ +export async function handleUnsubscribe(ctx, db) { + if (!db) { + await ctx.reply("lolschedule: storage unavailable"); + return; + } + const chatId = ctx.chat?.id; + if (chatId == null) { + await ctx.reply("Could not read chat id — unsubscribe failed."); + return; + } + const removed = await removeSubscriber(db, chatId); + await ctx.reply(removed ? "Unsubscribed." : "You weren't subscribed."); +} + +/** + * Cron handler — pushes today's major-league schedule to every subscribed chat. + * Per-chat failures are swallowed so one blocked bot cannot stop the fan-out. * * @param {any} _event * @param {{ db: import("../../db/kv-store-interface.js").KVStore, env: any }} ctx */ export async function handleDailyPushCron(_event, ctx) { const { db, env } = ctx; - const chatId = env?.LOLSCHEDULE_CHAT_ID; const token = env?.TELEGRAM_BOT_TOKEN; - if (!chatId || !token) { - console.log( - JSON.stringify({ - msg: "lolschedule_cron_skip", - reason: !chatId ? "no_chat_id" : "no_token", - }), - ); + if (!token) { + console.log(JSON.stringify({ msg: "lolschedule_cron_skip", reason: "no_token" })); + return; + } + const subscribers = await listSubscribers(db); + if (subscribers.length === 0) { + console.log(JSON.stringify({ msg: "lolschedule_cron_skip", reason: "no_subscribers" })); return; } const from = ictDayStart(); const to = addDays(from, 1); + let events; try { - const events = filterMajor(await getEventsCached(db, from, to)); - if (events.length === 0) { - console.log(JSON.stringify({ msg: "lolschedule_cron_empty" })); - return; - } - await sendTelegramMessage(token, chatId, renderToday(events, from)); - console.log(JSON.stringify({ msg: "lolschedule_cron_sent", events: events.length })); + events = filterMajor(await getEventsCached(db, from, to)); } catch (err) { console.log(JSON.stringify({ msg: "lolschedule_cron_fail", err: String(err) })); + return; } + if (events.length === 0) { + console.log(JSON.stringify({ msg: "lolschedule_cron_empty" })); + return; + } + const text = renderToday(events, from); + const results = await Promise.allSettled( + subscribers.map((chatId) => sendTelegramMessage(token, chatId, text)), + ); + const sent = results.filter((r) => r.status === "fulfilled").length; + const failed = results.length - sent; + console.log( + JSON.stringify({ msg: "lolschedule_cron_sent", events: events.length, sent, failed }), + ); } diff --git a/src/modules/lolschedule/index.js b/src/modules/lolschedule/index.js index 1904974..9015b2f 100644 --- a/src/modules/lolschedule/index.js +++ b/src/modules/lolschedule/index.js @@ -7,14 +7,20 @@ * /lolschedule_week — next 7 ICT days, grouped per day → league. * * Cron: - * 0 1 * * * (08:00 ICT) — push today's major-league schedule to - * LOLSCHEDULE_CHAT_ID when configured. + * 0 1 * * * (08:00 ICT) — push today's major-league schedule to every + * chat that has opted in via /lolschedule_subscribe. * * See the module README for data-source rationale and the verification * reports under plans/reports/ for historical context. */ -import { handleDailyPushCron, handleToday, handleWeek } from "./handlers.js"; +import { + handleDailyPushCron, + handleSubscribe, + handleToday, + handleUnsubscribe, + handleWeek, +} from "./handlers.js"; /** @type {import("../../db/kv-store-interface.js").KVStore | null} */ let db = null; @@ -38,6 +44,18 @@ const lolscheduleModule = { description: "LoL esports matches for the next 7 days", handler: (ctx) => handleWeek(ctx, db), }, + { + name: "lolschedule_subscribe", + visibility: "public", + description: "Get the daily LoL schedule digest at 08:00 ICT", + handler: (ctx) => handleSubscribe(ctx, db), + }, + { + name: "lolschedule_unsubscribe", + visibility: "public", + description: "Stop receiving the daily LoL schedule digest", + handler: (ctx) => handleUnsubscribe(ctx, db), + }, ], crons: [ { diff --git a/src/modules/lolschedule/subscribers.js b/src/modules/lolschedule/subscribers.js new file mode 100644 index 0000000..757d269 --- /dev/null +++ b/src/modules/lolschedule/subscribers.js @@ -0,0 +1,41 @@ +/** + * @file Subscriber list persisted in KV under the module's `subscribers` key. + * + * Stored as a plain JSON array of chat ids (numbers). Kept intentionally small + * and dependency-free — idempotent add/remove, returns a boolean indicating + * whether the list changed. + */ + +const KEY = "subscribers"; + +/** @param {import("../../db/kv-store-interface.js").KVStore} db */ +export async function listSubscribers(db) { + const raw = await db.getJSON(KEY); + return Array.isArray(raw) ? raw : []; +} + +/** + * @param {import("../../db/kv-store-interface.js").KVStore} db + * @param {number|string} chatId + * @returns {Promise} true if added, false if already subscribed + */ +export async function addSubscriber(db, chatId) { + const ids = await listSubscribers(db); + if (ids.includes(chatId)) return false; + ids.push(chatId); + await db.putJSON(KEY, ids); + return true; +} + +/** + * @param {import("../../db/kv-store-interface.js").KVStore} db + * @param {number|string} chatId + * @returns {Promise} true if removed, false if not subscribed + */ +export async function removeSubscriber(db, chatId) { + const ids = await listSubscribers(db); + const next = ids.filter((id) => id !== chatId); + if (next.length === ids.length) return false; + await db.putJSON(KEY, next); + return true; +} diff --git a/tests/modules/lolschedule/handlers.test.js b/tests/modules/lolschedule/handlers.test.js index d1f8129..9a51dd1 100644 --- a/tests/modules/lolschedule/handlers.test.js +++ b/tests/modules/lolschedule/handlers.test.js @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createStore } from "../../../src/db/create-store.js"; -import { handleDailyPushCron } from "../../../src/modules/lolschedule/handlers.js"; +import { + handleDailyPushCron, + handleSubscribe, + handleUnsubscribe, +} from "../../../src/modules/lolschedule/handlers.js"; +import { listSubscribers } from "../../../src/modules/lolschedule/subscribers.js"; import { makeFakeKv } from "../../fakes/fake-kv-namespace.js"; function scheduleResponse(events) { @@ -26,6 +31,56 @@ function majorEvt(startTime, leagueSlug = "lck", leagueName = "LCK") { }; } +function fakeCtx(chatId) { + const replied = []; + return { + chat: chatId != null ? { id: chatId } : undefined, + reply: async (text, opts) => replied.push({ text, opts }), + replied, + }; +} + +describe("handleSubscribe / handleUnsubscribe", () => { + let db; + beforeEach(() => { + db = createStore("lolschedule", { KV: makeFakeKv() }); + }); + + it("subscribes a new chat and persists the id", async () => { + const ctx = fakeCtx(10); + await handleSubscribe(ctx, db); + expect(ctx.replied[0].text).toMatch(/Subscribed/); + expect(await listSubscribers(db)).toEqual([10]); + }); + + it("tells an already-subscribed chat they're in", async () => { + const ctx = fakeCtx(10); + await handleSubscribe(ctx, db); + await handleSubscribe(ctx, db); + expect(ctx.replied[1].text).toMatch(/Already subscribed/); + }); + + it("unsubscribes an existing chat", async () => { + const ctx = fakeCtx(10); + await handleSubscribe(ctx, db); + await handleUnsubscribe(ctx, db); + expect(ctx.replied[1].text).toMatch(/Unsubscribed/); + expect(await listSubscribers(db)).toEqual([]); + }); + + it("tells a non-subscriber they weren't subscribed", async () => { + const ctx = fakeCtx(10); + await handleUnsubscribe(ctx, db); + expect(ctx.replied[0].text).toMatch(/weren't subscribed/); + }); + + it("warns when chat id is missing", async () => { + const ctx = fakeCtx(null); + await handleSubscribe(ctx, db); + expect(ctx.replied[0].text).toMatch(/Could not read chat id/); + }); +}); + describe("handleDailyPushCron", () => { let db; let telegramSpy; @@ -38,50 +93,64 @@ describe("handleDailyPushCron", () => { afterEach(() => vi.restoreAllMocks()); function mockFetch(scheduleEvents) { - global.fetch = vi.fn(async (url) => { + global.fetch = vi.fn(async (url, opts) => { if (String(url).includes("esports-api.lolesports.com")) { return scheduleResponse(scheduleEvents); } - return telegramSpy(url); + return telegramSpy(url, opts); }); } - it("skips cleanly when LOLSCHEDULE_CHAT_ID is missing", async () => { + it("skips cleanly when the bot token is missing", async () => { + mockFetch([majorEvt("2026-04-21T09:00:00Z")]); + await db.putJSON("subscribers", [123]); + await handleDailyPushCron({}, { db, env: {} }); + expect(telegramSpy).not.toHaveBeenCalled(); + }); + + it("skips cleanly when there are no subscribers", async () => { mockFetch([majorEvt("2026-04-21T09:00:00Z")]); await handleDailyPushCron({}, { db, env: { TELEGRAM_BOT_TOKEN: "t" } }); expect(telegramSpy).not.toHaveBeenCalled(); }); - it("skips cleanly when TELEGRAM_BOT_TOKEN is missing", async () => { - mockFetch([majorEvt("2026-04-21T09:00:00Z")]); - await handleDailyPushCron({}, { db, env: { LOLSCHEDULE_CHAT_ID: "123" } }); + it("does not fan out when no major-league matches today", async () => { + mockFetch([majorEvt("2026-04-21T09:00:00Z", "foo-cup", "Foo Cup")]); + await db.putJSON("subscribers", [1, 2]); + await handleDailyPushCron({}, { db, env: { TELEGRAM_BOT_TOKEN: "t" } }); expect(telegramSpy).not.toHaveBeenCalled(); }); - it("does not send when no major-league matches today", async () => { - mockFetch([majorEvt("2026-04-21T09:00:00Z", "lck-challengers", "LCK CL")]); - await handleDailyPushCron( - {}, - { db, env: { LOLSCHEDULE_CHAT_ID: "123", TELEGRAM_BOT_TOKEN: "t" } }, - ); - expect(telegramSpy).not.toHaveBeenCalled(); - }); - - it("sends the rendered today message when major-league matches exist", async () => { - // Use a time within the current ICT day window so fetchEventsInRange keeps it. + it("sends once per subscriber when matches exist today", async () => { const ictOffsetMs = 7 * 60 * 60 * 1000; const now = Date.now(); const ictDayStart = new Date(new Date(now + ictOffsetMs).setUTCHours(0, 0, 0, 0) - ictOffsetMs); const pickTime = new Date(ictDayStart.getTime() + 12 * 60 * 60 * 1000).toISOString(); - mockFetch([majorEvt(pickTime)]); - await handleDailyPushCron( - {}, - { db, env: { LOLSCHEDULE_CHAT_ID: "42", TELEGRAM_BOT_TOKEN: "tok" } }, - ); + await db.putJSON("subscribers", [100, 200, 300]); - expect(telegramSpy).toHaveBeenCalledOnce(); - const [url] = telegramSpy.mock.calls[0]; - expect(String(url)).toContain("/bottok/sendMessage"); + await handleDailyPushCron({}, { db, env: { TELEGRAM_BOT_TOKEN: "tok" } }); + expect(telegramSpy).toHaveBeenCalledTimes(3); + + const bodies = telegramSpy.mock.calls.map((c) => JSON.parse(c[1].body)); + expect(bodies.map((b) => b.chat_id).sort()).toEqual([100, 200, 300]); + }); + + it("continues fanning out when one subscriber fails", async () => { + const ictOffsetMs = 7 * 60 * 60 * 1000; + const ictDayStart = new Date( + new Date(Date.now() + ictOffsetMs).setUTCHours(0, 0, 0, 0) - ictOffsetMs, + ); + const pickTime = new Date(ictDayStart.getTime() + 12 * 60 * 60 * 1000).toISOString(); + mockFetch([majorEvt(pickTime)]); + await db.putJSON("subscribers", [1, 2]); + + // First Telegram call rejects, second resolves + telegramSpy + .mockImplementationOnce(async () => ({ ok: false, status: 403, text: async () => "blocked" })) + .mockImplementationOnce(async () => ({ ok: true, status: 200, text: async () => "{}" })); + + await handleDailyPushCron({}, { db, env: { TELEGRAM_BOT_TOKEN: "tok" } }); + expect(telegramSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/modules/lolschedule/subscribers.test.js b/tests/modules/lolschedule/subscribers.test.js new file mode 100644 index 0000000..02ac6a4 --- /dev/null +++ b/tests/modules/lolschedule/subscribers.test.js @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createStore } from "../../../src/db/create-store.js"; +import { + addSubscriber, + listSubscribers, + removeSubscriber, +} from "../../../src/modules/lolschedule/subscribers.js"; +import { makeFakeKv } from "../../fakes/fake-kv-namespace.js"; + +describe("subscribers", () => { + let db; + beforeEach(() => { + db = createStore("lolschedule", { KV: makeFakeKv() }); + }); + + it("starts empty", async () => { + expect(await listSubscribers(db)).toEqual([]); + }); + + it("adds a new subscriber and returns true", async () => { + expect(await addSubscriber(db, 42)).toBe(true); + expect(await listSubscribers(db)).toEqual([42]); + }); + + it("add is idempotent for an already-subscribed chat", async () => { + await addSubscriber(db, 42); + expect(await addSubscriber(db, 42)).toBe(false); + expect(await listSubscribers(db)).toEqual([42]); + }); + + it("removes an existing subscriber and returns true", async () => { + await addSubscriber(db, 1); + await addSubscriber(db, 2); + expect(await removeSubscriber(db, 1)).toBe(true); + expect(await listSubscribers(db)).toEqual([2]); + }); + + it("remove on a non-subscriber returns false and is a no-op", async () => { + await addSubscriber(db, 1); + expect(await removeSubscriber(db, 99)).toBe(false); + expect(await listSubscribers(db)).toEqual([1]); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index 76a6415..aa7a995 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -7,11 +7,6 @@ compatibility_date = "2025-10-01" [vars] MODULES = "util,wordle,loldle,misc,trading,lolschedule" -# Optional — chat id the lolschedule cron pushes the daily digest to. -# Leave unset to disable the push. To keep it out of git, prefer: -# wrangler secret put LOLSCHEDULE_CHAT_ID -# LOLSCHEDULE_CHAT_ID = "123456789" - # KV namespace holding all module state. Each module auto-prefixes its keys via createStore(). # Create with: # wrangler kv namespace create miti99bot-kv