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:
2026-04-21 10:23:17 +07:00
committed by Tien Nguyen Minh
parent c69d1749b7
commit 3e4e0e5b6e
7 changed files with 270 additions and 54 deletions
+4 -2
View File
@@ -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
+66 -18
View File
@@ -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 }),
);
}
+21 -3
View File
@@ -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: [
{
+41
View File
@@ -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;
}