diff --git a/src/modules/lolschedule/README.md b/src/modules/lolschedule/README.md index 4e57250..9cabfed 100644 --- a/src/modules/lolschedule/README.md +++ b/src/modules/lolschedule/README.md @@ -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. diff --git a/src/modules/lolschedule/format.js b/src/modules/lolschedule/format.js index d34b4e5..68248a5 100644 --- a/src/modules/lolschedule/format.js +++ b/src/modules/lolschedule/format.js @@ -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 ? `${t1Label}` : t1Label; - const r = winner2 ? `${t2Label}` : t2Label; - return `✅ ${l} ${w1}–${w2} ${r}${bo} · ${league}${block}`; + const l = teams[0]?.result?.outcome === "win" ? `${t1Label}` : t1Label; + const r = teams[1]?.result?.outcome === "win" ? `${t2Label}` : 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} */ + 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 `${escapeHtml(group.name)}\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 = `LoL — ${escapeHtml(formatIctDayLabel(day))} (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 = `LoL — ${fromLbl} → ${toLbl} (ICT)`; if (events.length === 0) return `${header}\nNo matches this week.`; - /** @type {Map} */ - const groups = new Map(); + /** @type {Map} */ + 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(`${escapeHtml(g.label)}\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(`${escapeHtml(day.label)}\n${leagueSections.join("\n")}`); } - return `${header}\n\n${sections.join("\n\n")}`; + return `${header}\n\n${dayBlocks.join("\n\n")}`; } diff --git a/src/modules/lolschedule/handlers.js b/src/modules/lolschedule/handlers.js index fe6ab44..2140c56 100644 --- a/src/modules/lolschedule/handlers.js +++ b/src/modules/lolschedule/handlers.js @@ -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) })); + } +} diff --git a/src/modules/lolschedule/index.js b/src/modules/lolschedule/index.js index d6c8205..1904974 100644 --- a/src/modules/lolschedule/index.js +++ b/src/modules/lolschedule/index.js @@ -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; diff --git a/tests/modules/lolschedule/format.test.js b/tests/modules/lolschedule/format.test.js index eeea79f..ffc81f0 100644 --- a/tests/modules/lolschedule/format.test.js +++ b/tests/modules/lolschedule/format.test.js @@ -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: "