mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 08:18:44 +00:00
feat(lolschedule): group by league, rename commands, add daily push cron
- Commands renamed: /lol_today → /lolschedule_today, /lol_week → /lolschedule_week. - Today view groups events under a league header per section. - Week view nests leagues under each ICT day. - LEAGUE_ORDER gives tier-1 tournaments priority (Worlds / MSI / First Stand, then LCK / LPL / LEC / LCS, etc). - New cron "0 1 * * *" (08:00 ICT) pushes today's major-league schedule to LOLSCHEDULE_CHAT_ID via the Telegram Bot API. Skips cleanly when chat id or token is missing, or when today has no major-league matches.
This commit is contained in:
1 parent
cc5eb2d473
commit
3c5bfb6910
7 files changed
+364
-97
No files matched your search
@@ -6,8 +6,16 @@ LoL esports match schedule via the **lolesports.com** esports-api (the data feed
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/lol_today` | Today's matches (ICT). Scores for played + live. Times for upcoming. |
|
||||
| `/lol_week` | Next 7 days, grouped by day. |
|
||||
| `/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. |
|
||||
|
||||
## 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. |
|
||||
|
||||
Set the chat id with `wrangler secret put LOLSCHEDULE_CHAT_ID` (recommended) or as a `[vars]` entry in `wrangler.toml`.
|
||||
|
||||
## Data source
|
||||
|
||||
@@ -50,6 +58,12 @@ Cache-first with KV. Key is `matches:{fromIso}:{toIso}`.
|
||||
- Stale fallback: up to 1 h on upstream failure
|
||||
- No cron pre-warm needed — upstream is cheap
|
||||
|
||||
## Grouping
|
||||
|
||||
- `/lolschedule_today` — one section per league (header + match lines).
|
||||
- `/lolschedule_week` — one section per ICT day; within each day, leagues are sub-grouped.
|
||||
- League ordering follows `LEAGUE_ORDER` in `format.js` (worlds / msi / first_stand first, then LCK / LPL / LEC / LCS, then the rest).
|
||||
|
||||
## Time zone
|
||||
|
||||
All rendering is in **ICT (UTC+7)**. `startTime` is UTC ISO; day boundaries for the `/lol_today` and `/lol_week` windows are anchored to ICT midnight.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* @file Pure formatters — Telegram HTML output for today / week match lists.
|
||||
*
|
||||
* Takes lolesports.com schedule events and renders them. All user-influenced
|
||||
* substrings are HTML-escaped. Times are shown in ICT (UTC+7).
|
||||
* Takes lolesports.com schedule events and renders them, grouped by league
|
||||
* (today) and by day → league (week). All user-influenced substrings are
|
||||
* HTML-escaped. Times are shown in ICT (UTC+7).
|
||||
*/
|
||||
|
||||
import { escapeHtml } from "../../util/escape-html.js";
|
||||
@@ -12,6 +13,20 @@ import { escapeHtml } from "../../util/escape-html.js";
|
||||
|
||||
const TZ_OFFSET_MS = 7 * 60 * 60 * 1000; // ICT = UTC+7
|
||||
|
||||
/** Ordering for league sections — most prestigious tournaments first. */
|
||||
const LEAGUE_ORDER = [
|
||||
"worlds",
|
||||
"msi",
|
||||
"first_stand",
|
||||
"lck",
|
||||
"lpl",
|
||||
"lec",
|
||||
"lcs",
|
||||
"lcp",
|
||||
"cblol-brazil",
|
||||
"emea_masters",
|
||||
];
|
||||
|
||||
/** Shift a UTC Date by the ICT offset so getUTC* yields ICT components. */
|
||||
function toIct(date) {
|
||||
return new Date(date.getTime() + TZ_OFFSET_MS);
|
||||
@@ -58,41 +73,75 @@ function teamLabel(team) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one event line (no leading newline).
|
||||
* Render one event line (no leading newline). By default the league name is
|
||||
* omitted because events are rendered under a league header; pass
|
||||
* `{ showLeague: true }` to include it for flat lists.
|
||||
*
|
||||
* @param {ScheduleEvent} event
|
||||
* @param {{ showLeague?: boolean }} [opts]
|
||||
* @returns {string} escaped HTML
|
||||
*/
|
||||
export function formatEventLine(event) {
|
||||
export function formatEventLine(event, { showLeague = false } = {}) {
|
||||
const teams = event?.match?.teams || [];
|
||||
const t1Label = escapeHtml(teamLabel(teams[0]));
|
||||
const t2Label = escapeHtml(teamLabel(teams[1]));
|
||||
const league = escapeHtml(event?.league?.name || "");
|
||||
const block = event?.blockName ? ` (${escapeHtml(event.blockName)})` : "";
|
||||
const bestOf = event?.match?.strategy?.count;
|
||||
const bo = bestOf ? ` · Bo${bestOf}` : "";
|
||||
const leagueSuffix =
|
||||
showLeague && event?.league?.name ? ` · ${escapeHtml(event.league.name)}` : "";
|
||||
|
||||
if (event?.state === "completed") {
|
||||
const w1 = teams[0]?.result?.gameWins ?? 0;
|
||||
const w2 = teams[1]?.result?.gameWins ?? 0;
|
||||
const winner1 = teams[0]?.result?.outcome === "win";
|
||||
const winner2 = teams[1]?.result?.outcome === "win";
|
||||
const l = winner1 ? `<b>${t1Label}</b>` : t1Label;
|
||||
const r = winner2 ? `<b>${t2Label}</b>` : t2Label;
|
||||
return `✅ ${l} ${w1}–${w2} ${r}${bo} · ${league}${block}`;
|
||||
const l = teams[0]?.result?.outcome === "win" ? `<b>${t1Label}</b>` : t1Label;
|
||||
const r = teams[1]?.result?.outcome === "win" ? `<b>${t2Label}</b>` : t2Label;
|
||||
return `✅ ${l} ${w1}–${w2} ${r}${bo}${leagueSuffix}${block}`;
|
||||
}
|
||||
if (event?.state === "inProgress") {
|
||||
const w1 = teams[0]?.result?.gameWins ?? 0;
|
||||
const w2 = teams[1]?.result?.gameWins ?? 0;
|
||||
return `🔴 LIVE ${t1Label} ${w1}–${w2} ${t2Label}${bo} · ${league}${block}`;
|
||||
return `🔴 LIVE ${t1Label} ${w1}–${w2} ${t2Label}${bo}${leagueSuffix}${block}`;
|
||||
}
|
||||
// unstarted or unknown
|
||||
const time = formatIctTime(new Date(event.startTime));
|
||||
return `🕒 ${time} ${t1Label} vs ${t2Label}${bo} · ${league}${block}`;
|
||||
return `🕒 ${time} ${t1Label} vs ${t2Label}${bo}${leagueSuffix}${block}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render today's reply.
|
||||
* Group events by league slug, preserving LEAGUE_ORDER for known leagues and
|
||||
* falling back to alphabetical for anything else.
|
||||
*
|
||||
* @param {ScheduleEvent[]} events
|
||||
* @returns {Array<{ slug: string, name: string, events: ScheduleEvent[] }>}
|
||||
*/
|
||||
function groupByLeague(events) {
|
||||
/** @type {Map<string, { slug: string, name: string, events: ScheduleEvent[] }>} */
|
||||
const bySlug = new Map();
|
||||
for (const event of events) {
|
||||
const slug = event?.league?.slug || "unknown";
|
||||
const name = event?.league?.name || slug;
|
||||
let g = bySlug.get(slug);
|
||||
if (!g) {
|
||||
g = { slug, name, events: [] };
|
||||
bySlug.set(slug, g);
|
||||
}
|
||||
g.events.push(event);
|
||||
}
|
||||
const known = LEAGUE_ORDER.filter((slug) => bySlug.has(slug)).map((slug) => bySlug.get(slug));
|
||||
const unknown = [...bySlug.values()]
|
||||
.filter((g) => !LEAGUE_ORDER.includes(g.slug))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return [...known, ...unknown];
|
||||
}
|
||||
|
||||
/** Render a league section (header + lines). */
|
||||
function renderLeagueSection(group) {
|
||||
const lines = group.events.map((e) => formatEventLine(e));
|
||||
return `<b>${escapeHtml(group.name)}</b>\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render today's reply — grouped by league.
|
||||
*
|
||||
* @param {ScheduleEvent[]} events
|
||||
* @param {Date} day — any moment on the target ICT day.
|
||||
@@ -101,11 +150,12 @@ export function formatEventLine(event) {
|
||||
export function renderToday(events, day) {
|
||||
const header = `<b>LoL — ${escapeHtml(formatIctDayLabel(day))}</b> (ICT)`;
|
||||
if (events.length === 0) return `${header}\nNo matches today.`;
|
||||
return `${header}\n${events.map(formatEventLine).join("\n")}`;
|
||||
const sections = groupByLeague(events).map(renderLeagueSection);
|
||||
return `${header}\n\n${sections.join("\n\n")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render week reply — grouped by ICT day.
|
||||
* Render week reply — grouped by ICT day → league.
|
||||
*
|
||||
* @param {ScheduleEvent[]} events
|
||||
* @param {Date} from
|
||||
@@ -118,23 +168,24 @@ export function renderWeek(events, from, to) {
|
||||
const header = `<b>LoL — ${fromLbl} → ${toLbl}</b> (ICT)`;
|
||||
if (events.length === 0) return `${header}\nNo matches this week.`;
|
||||
|
||||
/** @type {Map<string, { label: string, lines: string[] }>} */
|
||||
const groups = new Map();
|
||||
/** @type {Map<string, { label: string, events: ScheduleEvent[] }>} */
|
||||
const days = new Map();
|
||||
for (const event of events) {
|
||||
const d = new Date(event.startTime);
|
||||
const key = ictDayKey(d);
|
||||
let g = groups.get(key);
|
||||
let g = days.get(key);
|
||||
if (!g) {
|
||||
g = { label: formatIctDayLabel(d), lines: [] };
|
||||
groups.set(key, g);
|
||||
g = { label: formatIctDayLabel(d), events: [] };
|
||||
days.set(key, g);
|
||||
}
|
||||
g.lines.push(formatEventLine(event));
|
||||
g.events.push(event);
|
||||
}
|
||||
|
||||
const sections = [];
|
||||
for (const key of [...groups.keys()].sort()) {
|
||||
const g = groups.get(key);
|
||||
sections.push(`<b>${escapeHtml(g.label)}</b>\n${g.lines.join("\n")}`);
|
||||
const dayBlocks = [];
|
||||
for (const key of [...days.keys()].sort()) {
|
||||
const day = days.get(key);
|
||||
const leagueSections = groupByLeague(day.events).map(renderLeagueSection);
|
||||
dayBlocks.push(`<b>${escapeHtml(day.label)}</b>\n${leagueSections.join("\n")}`);
|
||||
}
|
||||
return `${header}\n\n${sections.join("\n\n")}`;
|
||||
return `${header}\n\n${dayBlocks.join("\n\n")}`;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* @file /lol_today and /lol_week command handlers.
|
||||
* @file Command + cron handlers for lolschedule.
|
||||
*
|
||||
* Day boundaries are defined in ICT (UTC+7). Data comes from lolesports.com
|
||||
* via a cache-first fetcher; no cron pre-warm is needed because the upstream
|
||||
* API is rate-limit friendly.
|
||||
* API is rate-limit friendly. A daily cron also pushes today's schedule to a
|
||||
* configured chat.
|
||||
*/
|
||||
|
||||
import { getEventsCached } from "./api-client.js";
|
||||
@@ -82,3 +83,64 @@ export async function handleWeek(ctx, db) {
|
||||
await ctx.reply("Could not fetch this week's matches. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Telegram HTML message directly via the Bot API. Used from the cron
|
||||
* path where the grammY bot context is not available.
|
||||
*
|
||||
* @param {string} token
|
||||
* @param {string|number} chatId
|
||||
* @param {string} text
|
||||
*/
|
||||
async function sendTelegramMessage(token, chatId, text) {
|
||||
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`telegram sendMessage HTTP ${res.status}: ${body.slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @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",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const from = ictDayStart();
|
||||
const to = addDays(from, 1);
|
||||
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 }));
|
||||
} catch (err) {
|
||||
console.log(JSON.stringify({ msg: "lolschedule_cron_fail", err: String(err) }));
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,18 @@
|
||||
* lolesports.com esports-api (the data feed behind lolesports.com).
|
||||
*
|
||||
* Commands:
|
||||
* /lol_today — matches scheduled for the current ICT day, with live/played scores.
|
||||
* /lol_week — next 7 ICT days, grouped per day.
|
||||
* /lolschedule_today — matches scheduled for the current ICT day, with live/played scores.
|
||||
* /lolschedule_week — next 7 ICT days, grouped per day → league.
|
||||
*
|
||||
* See the module README for the data-source rationale and the verification
|
||||
* Cron:
|
||||
* 0 1 * * * (08:00 ICT) — push today's major-league schedule to
|
||||
* LOLSCHEDULE_CHAT_ID when configured.
|
||||
*
|
||||
* See the module README for data-source rationale and the verification
|
||||
* reports under plans/reports/ for historical context.
|
||||
*/
|
||||
|
||||
import { handleToday, handleWeek } from "./handlers.js";
|
||||
import { handleDailyPushCron, handleToday, handleWeek } from "./handlers.js";
|
||||
|
||||
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
|
||||
let db = null;
|
||||
@@ -23,18 +27,25 @@ const lolscheduleModule = {
|
||||
},
|
||||
commands: [
|
||||
{
|
||||
name: "lol_today",
|
||||
name: "lolschedule_today",
|
||||
visibility: "public",
|
||||
description: "Today's LoL esports matches (scores if played)",
|
||||
handler: (ctx) => handleToday(ctx, db),
|
||||
},
|
||||
{
|
||||
name: "lol_week",
|
||||
name: "lolschedule_week",
|
||||
visibility: "public",
|
||||
description: "LoL esports matches for the next 7 days",
|
||||
handler: (ctx) => handleWeek(ctx, db),
|
||||
},
|
||||
],
|
||||
crons: [
|
||||
{
|
||||
schedule: "0 1 * * *",
|
||||
name: "daily-push",
|
||||
handler: handleDailyPushCron,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default lolscheduleModule;
|
||||
@@ -9,13 +9,30 @@ import {
|
||||
|
||||
/** @typedef {import("../../../src/modules/lolschedule/api-client.js").ScheduleEvent} ScheduleEvent */
|
||||
|
||||
const completed = /** @type {ScheduleEvent} */ ({
|
||||
function evt(overrides = {}) {
|
||||
return /** @type {ScheduleEvent} */ ({
|
||||
startTime: "2026-04-21T09:00:00Z",
|
||||
state: "unstarted",
|
||||
blockName: "Week 4",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
match: {
|
||||
id: "m1",
|
||||
teams: [
|
||||
{ name: "T1", code: "T1" },
|
||||
{ name: "Gen.G", code: "GEN" },
|
||||
],
|
||||
strategy: { type: "bestOf", count: 3 },
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const completed = evt({
|
||||
startTime: "2026-04-20T08:00:00Z",
|
||||
state: "completed",
|
||||
blockName: "Week 3",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
match: {
|
||||
id: "1",
|
||||
id: "m2",
|
||||
teams: [
|
||||
{ name: "T1", code: "T1", result: { outcome: "win", gameWins: 2 } },
|
||||
{ name: "Gen.G", code: "GEN", result: { outcome: "loss", gameWins: 1 } },
|
||||
@@ -24,13 +41,11 @@ const completed = /** @type {ScheduleEvent} */ ({
|
||||
},
|
||||
});
|
||||
|
||||
const live = /** @type {ScheduleEvent} */ ({
|
||||
const live = evt({
|
||||
startTime: "2026-04-21T08:00:00Z",
|
||||
state: "inProgress",
|
||||
blockName: "Week 4",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
match: {
|
||||
id: "2",
|
||||
id: "m3",
|
||||
teams: [
|
||||
{ name: "Hanwha Life", code: "HLE", result: { gameWins: 1 } },
|
||||
{ name: "DRX", code: "DRX", result: { gameWins: 0 } },
|
||||
@@ -39,21 +54,6 @@ const live = /** @type {ScheduleEvent} */ ({
|
||||
},
|
||||
});
|
||||
|
||||
const scheduled = /** @type {ScheduleEvent} */ ({
|
||||
startTime: "2026-04-21T09:00:00Z", // 16:00 ICT
|
||||
state: "unstarted",
|
||||
blockName: "Week 4",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
match: {
|
||||
id: "3",
|
||||
teams: [
|
||||
{ name: "KT Rolster", code: "KT" },
|
||||
{ name: "Dplus KIA", code: "DK" },
|
||||
],
|
||||
strategy: { type: "bestOf", count: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
describe("formatIctTime / formatIctDayLabel", () => {
|
||||
it("formats UTC datetime in ICT", () => {
|
||||
expect(formatIctTime(new Date("2026-04-21T09:00:00Z"))).toBe("16:00");
|
||||
@@ -62,6 +62,14 @@ describe("formatIctTime / formatIctDayLabel", () => {
|
||||
});
|
||||
|
||||
describe("formatEventLine", () => {
|
||||
it("omits league name by default (renders under league header)", () => {
|
||||
expect(formatEventLine(evt())).not.toContain("LCK");
|
||||
});
|
||||
|
||||
it("includes league name when showLeague is true", () => {
|
||||
expect(formatEventLine(evt(), { showLeague: true })).toContain("LCK");
|
||||
});
|
||||
|
||||
it("renders completed with bolded winner + score", () => {
|
||||
const line = formatEventLine(completed);
|
||||
expect(line.startsWith("✅")).toBe(true);
|
||||
@@ -69,7 +77,6 @@ describe("formatEventLine", () => {
|
||||
expect(line).toContain("2–1");
|
||||
expect(line).toContain("GEN");
|
||||
expect(line).toContain("Bo3");
|
||||
expect(line).toContain("LCK");
|
||||
expect(line).toContain("Week 3");
|
||||
});
|
||||
|
||||
@@ -80,34 +87,38 @@ describe("formatEventLine", () => {
|
||||
});
|
||||
|
||||
it("renders unstarted with ICT time + vs", () => {
|
||||
const line = formatEventLine(scheduled);
|
||||
const line = formatEventLine(evt());
|
||||
expect(line.startsWith("🕒 16:00")).toBe(true);
|
||||
expect(line).toContain("KT vs DK");
|
||||
expect(line).toContain("T1 vs GEN");
|
||||
});
|
||||
|
||||
it("escapes HTML in league, block, team names", () => {
|
||||
const event = /** @type {ScheduleEvent} */ ({
|
||||
...scheduled,
|
||||
league: { name: "A&B", slug: "ab" },
|
||||
blockName: "<script>",
|
||||
match: {
|
||||
...scheduled.match,
|
||||
teams: [{ code: "<bad>" }, { code: "GEN" }],
|
||||
},
|
||||
});
|
||||
const line = formatEventLine(event);
|
||||
it("escapes HTML in block and team labels", () => {
|
||||
const line = formatEventLine(
|
||||
evt({
|
||||
blockName: "<script>",
|
||||
match: {
|
||||
id: "x",
|
||||
teams: [{ code: "<bad>" }, { code: "GEN" }],
|
||||
strategy: { type: "bestOf", count: 3 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(line).not.toContain("<script>");
|
||||
expect(line).toContain("<script>");
|
||||
expect(line).toContain("<bad>");
|
||||
expect(line).toContain("A&B");
|
||||
});
|
||||
|
||||
it("shows TBD when team is missing", () => {
|
||||
const event = /** @type {ScheduleEvent} */ ({
|
||||
...scheduled,
|
||||
match: { ...scheduled.match, teams: [null, { code: "GEN" }] },
|
||||
});
|
||||
expect(formatEventLine(event)).toContain("TBD vs GEN");
|
||||
it("shows TBD when a team is missing", () => {
|
||||
const line = formatEventLine(
|
||||
evt({
|
||||
match: {
|
||||
id: "x",
|
||||
teams: [null, { code: "GEN" }],
|
||||
strategy: { type: "bestOf", count: 3 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(line).toContain("TBD vs GEN");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,19 +127,50 @@ describe("renderToday", () => {
|
||||
expect(renderToday([], new Date("2026-04-21T00:00:00Z"))).toContain("No matches today.");
|
||||
});
|
||||
|
||||
it("renders header + one line per event", () => {
|
||||
const out = renderToday([scheduled, live], new Date("2026-04-21T00:00:00Z"));
|
||||
expect(out).toContain("<b>LoL — Tue Apr 21</b>");
|
||||
expect(out.split("\n")).toHaveLength(3);
|
||||
it("groups by league with a header per league", () => {
|
||||
const events = [
|
||||
evt({ league: { name: "LPL", slug: "lpl" }, startTime: "2026-04-21T07:00:00Z" }),
|
||||
evt({ league: { name: "LCK", slug: "lck" }, startTime: "2026-04-21T08:00:00Z" }),
|
||||
evt({ league: { name: "LCK", slug: "lck" }, startTime: "2026-04-21T09:00:00Z" }),
|
||||
];
|
||||
const out = renderToday(events, new Date("2026-04-21T00:00:00Z"));
|
||||
// LCK section appears before LPL because LEAGUE_ORDER ranks LCK higher
|
||||
expect(out).toMatch(/<b>LCK<\/b>[\s\S]*<b>LPL<\/b>/);
|
||||
// Two lines under LCK, one under LPL
|
||||
expect((out.match(/🕒/g) || []).length).toBe(3);
|
||||
});
|
||||
|
||||
it("ranks known leagues before unknown slug", () => {
|
||||
const events = [
|
||||
evt({ league: { name: "Foo Cup", slug: "foo-cup" } }),
|
||||
evt({ league: { name: "LCK", slug: "lck" } }),
|
||||
];
|
||||
const out = renderToday(events, new Date("2026-04-21T00:00:00Z"));
|
||||
expect(out.indexOf("<b>LCK</b>")).toBeLessThan(out.indexOf("<b>Foo Cup</b>"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderWeek", () => {
|
||||
it("groups events by ICT day in order", () => {
|
||||
it("empty state when no events", () => {
|
||||
expect(
|
||||
renderWeek([], new Date("2026-04-21T00:00:00Z"), new Date("2026-04-28T00:00:00Z")),
|
||||
).toContain("No matches this week.");
|
||||
});
|
||||
|
||||
it("nests leagues under each ICT day in chronological order", () => {
|
||||
const events = [
|
||||
{ ...scheduled, startTime: "2026-04-21T09:00:00Z" },
|
||||
{ ...scheduled, startTime: "2026-04-22T10:00:00Z" },
|
||||
{ ...scheduled, startTime: "2026-04-22T12:00:00Z" },
|
||||
evt({
|
||||
startTime: "2026-04-21T09:00:00Z",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
}),
|
||||
evt({
|
||||
startTime: "2026-04-22T09:00:00Z",
|
||||
league: { name: "LPL", slug: "lpl" },
|
||||
}),
|
||||
evt({
|
||||
startTime: "2026-04-22T11:00:00Z",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
}),
|
||||
];
|
||||
const out = renderWeek(
|
||||
events,
|
||||
@@ -136,13 +178,8 @@ describe("renderWeek", () => {
|
||||
new Date("2026-04-28T00:00:00Z"),
|
||||
);
|
||||
expect(out.indexOf("Apr 21")).toBeLessThan(out.indexOf("Apr 22"));
|
||||
const apr22Section = out.split("Apr 22")[1] || "";
|
||||
expect((apr22Section.match(/🕒/g) || []).length).toBe(2);
|
||||
});
|
||||
|
||||
it("empty state when no events", () => {
|
||||
expect(
|
||||
renderWeek([], new Date("2026-04-21T00:00:00Z"), new Date("2026-04-28T00:00:00Z")),
|
||||
).toContain("No matches this week.");
|
||||
// Apr 22: LCK section appears before LPL (per LEAGUE_ORDER)
|
||||
const apr22Block = out.split("Apr 22")[1] || "";
|
||||
expect(apr22Block.indexOf("<b>LCK</b>")).toBeLessThan(apr22Block.indexOf("<b>LPL</b>"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
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 { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
||||
|
||||
function scheduleResponse(events) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ data: { schedule: { events, pages: {} } } }),
|
||||
};
|
||||
}
|
||||
|
||||
function majorEvt(startTime, leagueSlug = "lck", leagueName = "LCK") {
|
||||
return {
|
||||
startTime,
|
||||
state: "unstarted",
|
||||
type: "match",
|
||||
blockName: "W1",
|
||||
league: { name: leagueName, slug: leagueSlug },
|
||||
match: {
|
||||
id: `m-${startTime}`,
|
||||
teams: [{ code: "T1" }, { code: "GEN" }],
|
||||
strategy: { type: "bestOf", count: 3 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleDailyPushCron", () => {
|
||||
let db;
|
||||
let telegramSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createStore("lolschedule", { KV: makeFakeKv() });
|
||||
telegramSpy = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "{}" });
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function mockFetch(scheduleEvents) {
|
||||
global.fetch = vi.fn(async (url) => {
|
||||
if (String(url).includes("esports-api.lolesports.com")) {
|
||||
return scheduleResponse(scheduleEvents);
|
||||
}
|
||||
return telegramSpy(url);
|
||||
});
|
||||
}
|
||||
|
||||
it("skips cleanly when LOLSCHEDULE_CHAT_ID is missing", 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" } });
|
||||
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.
|
||||
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" } },
|
||||
);
|
||||
|
||||
expect(telegramSpy).toHaveBeenCalledOnce();
|
||||
const [url] = telegramSpy.mock.calls[0];
|
||||
expect(String(url)).toContain("/bottok/sendMessage");
|
||||
});
|
||||
});
|
||||
+6
-1
@@ -7,6 +7,11 @@ 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
|
||||
@@ -33,7 +38,7 @@ database_id = "261b54e7-0fdb-4fe7-8ed9-2e8a8bcf459c"
|
||||
# Local testing: curl "http://localhost:8787/__scheduled?cron=0+1+*+*+*"
|
||||
# (requires `wrangler dev --test-scheduled`)
|
||||
[triggers]
|
||||
crons = ["0 17 * * *"]
|
||||
crons = ["0 17 * * *", "0 1 * * *"]
|
||||
|
||||
# Workers Observability — captures console.* logs, request metadata, and
|
||||
# invocation traces in the Cloudflare dashboard (Observability → Logs).
|
||||
|
||||
Reference in new issue
Block a user