mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-27 22:20:32 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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<boolean>} 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<boolean>} 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user