diff --git a/src/modules/lolschedule/README.md b/src/modules/lolschedule/README.md index addc4ab..4e57250 100644 --- a/src/modules/lolschedule/README.md +++ b/src/modules/lolschedule/README.md @@ -1,41 +1,62 @@ # lolschedule -LoL esports match schedule via the **Leaguepedia** MediaWiki/Cargo API. +LoL esports match schedule via the **lolesports.com** esports-api (the data feed behind the official lolesports.com website). ## Commands | Command | Description | |---|---| -| `/lol_today` | Today's matches (ICT). Scores if played / live. Times if scheduled. | +| `/lol_today` | Today's matches (ICT). Scores for played + live. Times for upcoming. | | `/lol_week` | Next 7 days, grouped by day. | ## Data source -- Endpoint: `https://lol.fandom.com/api.php?action=cargoquery` -- Table: `MatchSchedule` -- Fields used: `DateTime_UTC`, `Team1`, `Team2`, `Team1Score`, `Team2Score`, `Winner`, `Tournament`, `BestOf`, `OverviewPage` -- Auth: none. UA header identifies the bot. +- Endpoint: `https://esports-api.lolesports.com/persisted/gw/getSchedule` +- Header: `x-api-key: ` — the same key lolesports.com's own web client sends. No registration, no token lifecycle. If Riot rotates it, lift the new value from their public JS bundle. +- Companion endpoints used during design: `getLive`, `getLeagues`. -See `plans/reports/researcher-260421-0845-leaguepedia-api-verification.md` and `plans/reports/researcher-260421-0909-leaguepedia-auth-token.md` for the feasibility verdict + rate-limit strategy. +## Why not Leaguepedia? + +Initial design hit Fandom's anonymous IP rate limit even from Cloudflare Worker egress (~1–2 req/min). See `plans/reports/researcher-260421-0845-leaguepedia-api-verification.md` and the auth-token follow-up. lolesports.com API is: + +- Official Riot-operated +- Rate-limit friendly (powers the live site) +- Richer shape: state (`unstarted` / `inProgress` / `completed`), per-team `result.gameWins`, league metadata, bestOf strategy + +## Event shape (relevant fields) + +``` +{ + startTime: "2026-04-21T09:00:00Z", + state: "unstarted" | "inProgress" | "completed", + blockName: "Week 4", + league: { name: "LCK", slug: "lck" }, + match: { + id: "...", + teams: [ + { name, code, image, result?: { outcome, gameWins }, record?: {...} }, + ... + ], + strategy: { type: "bestOf", count: 3 } + } +} +``` ## Caching -Two layers: +Cache-first with KV. Key is `matches:{fromIso}:{toIso}`. -1. **Worker edge cache** — `fetch(url, { cf: { cacheTtl: 30, cacheEverything: true }})` dedupes near-simultaneous calls across requests to the same edge POP. -2. **KV cache** (per module) — `matches:{fromIso}:{toIso}` with `ts`+`rows` payload. TTL: 60 s for `/lol_today`, 300 s for `/lol_week`. On fetch failure, stale cache (up to 4× TTL) is returned as a fallback. +- Fresh TTL: 120 s (catches live score updates quickly) +- Stale fallback: up to 1 h on upstream failure +- No cron pre-warm needed — upstream is cheap ## Time zone -All rendering is in **ICT (UTC+7)**. `DateTime_UTC` strings from the wiki are parsed as UTC, then shifted for display. Change `TZ_OFFSET_MS` in `format.js` if you need a different 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. ## Files -- `index.js` — module contract, registers `/lol_today` and `/lol_week`. -- `api-client.js` — cargoquery POST, range fetch, cache-first wrapper. -- `format.js` — pure renderers (`classifyMatch`, `formatMatchLine`, `renderToday`, `renderWeek`). -- `handlers.js` — grammY command handlers + ICT day-boundary helpers. - -## Known caveat - -The `where=` clause with `>=`/`<` operators hits `MWException` from shared egress IPs during verification (see auth-token report). POST + a proper UA appears to work from CF Worker egress, but confirm on first deploy. If it fails in production, fall back to `HOLDS`-based filters or a client-side filter over a broader fetch. +- `index.js` — module contract +- `api-client.js` — getSchedule client with pagination + cache +- `format.js` — pure renderers (`formatEventLine`, `renderToday`, `renderWeek`) +- `handlers.js` — grammY command handlers + ICT day-boundary helpers diff --git a/src/modules/lolschedule/api-client.js b/src/modules/lolschedule/api-client.js index d91a54d..5037b76 100644 --- a/src/modules/lolschedule/api-client.js +++ b/src/modules/lolschedule/api-client.js @@ -1,149 +1,157 @@ /** - * @file Leaguepedia cargoquery client for LoL esports match schedule. + * @file lolesports.com esports-api client. * - * Uses the MediaWiki Cargo extension on lol.fandom.com. No auth. We identify - * ourselves with a contact UA (Fandom policy) and cache via both the Worker - * edge cache (`cf.cacheTtl`) and the module's KVStore for cross-edge reuse. + * Endpoint: https://esports-api.lolesports.com/persisted/gw/getSchedule + * Auth: `x-api-key` header — the public key embedded in lolesports.com's own + * web client. No registration. No practical rate limit (the same key serves + * the live site). If Riot ever rotates it, lift the new value from their + * public JS bundle. * - * @see plans/reports/researcher-260421-0845-leaguepedia-api-verification.md + * We cache responses in KV keyed by league filter so concurrent user requests + * collapse to one upstream hit within the TTL window. */ -const API_URL = "https://lol.fandom.com/api.php"; -const USER_AGENT = "miti99bot/0.1 (https://t.me/miti99bot; minhtienit99@gmail.com)"; +const API_URL = "https://esports-api.lolesports.com/persisted/gw/getSchedule"; +const API_KEY = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z"; +const USER_AGENT = "miti99bot/0.1 (https://t.me/miti99bot)"; -/** Default KV cache windows — short enough to catch score updates mid-day. */ -const CACHE_TTL_TODAY_SEC = 60; -const CACHE_TTL_WEEK_SEC = 300; +/** Short cache — schedule data changes minute-by-minute during live events. */ +const CACHE_TTL_SEC = 120; +/** Stale fallback ceiling for resilience during upstream hiccups. */ +const STALE_MAX_AGE_SEC = 60 * 60; /** - * @typedef {object} MatchRow - * @property {string} DateTime — "YYYY-MM-DD HH:MM:SS" UTC (Leaguepedia convention). - * @property {string} T1 - * @property {string} T2 - * @property {string|null} S1 — team-1 score, may be empty string before match. - * @property {string|null} S2 - * @property {string|null} Winner — "1" | "2" | "" when unplayed. - * @property {string} Tournament - * @property {string|null} BO — best-of count as string. - * @property {string} OP — OverviewPage (wiki slug) for deep-link. + * @typedef {object} Team + * @property {string} name + * @property {string} code — short league tag, e.g. "T1", "GEN". + * @property {string} [image] + * @property {{ outcome: "win"|"loss", gameWins: number }} [result] + * @property {{ wins: number, losses: number }} [record] */ /** - * Low-level cargoquery POST. Uses POST to avoid edge WAF stripping of `>=`/`<` - * in the query string and to stay under URL-length limits for long where clauses. - * - * @param {Record} params - * @returns {Promise} array of row `title` objects + * @typedef {object} ScheduleEvent + * @property {string} startTime — ISO 8601 UTC. + * @property {"unstarted"|"inProgress"|"completed"} state + * @property {string} [blockName] — e.g. "Week 4". + * @property {{ name: string, slug: string, image?: string }} league + * @property {{ + * id: string, + * teams: Team[], + * strategy: { type: string, count: number } + * }} match */ -async function cargoQuery(params) { - const body = new URLSearchParams({ - action: "cargoquery", - format: "json", - ...params, - }); - const res = await fetch(API_URL, { - method: "POST", + +/** + * Fetch one page of schedule events. Returns events + pagination tokens. + * + * @param {object} [opts] + * @param {string} [opts.pageToken] — `newer`/`older` cursor from a previous call. + * @param {string} [opts.leagueId] — optional comma-separated league IDs. + * @returns {Promise<{ events: ScheduleEvent[], olderToken?: string, newerToken?: string }>} + */ +export async function fetchSchedulePage({ pageToken, leagueId } = {}) { + const url = new URL(API_URL); + url.searchParams.set("hl", "en-US"); + if (pageToken) url.searchParams.set("pageToken", pageToken); + if (leagueId) url.searchParams.set("leagueId", leagueId); + + const res = await fetch(url.toString(), { headers: { - "Content-Type": "application/x-www-form-urlencoded", + "x-api-key": API_KEY, "User-Agent": USER_AGENT, Accept: "application/json", }, - body, - cf: { cacheTtl: 30, cacheEverything: true }, + cf: { cacheTtl: 60, cacheEverything: true }, }); const text = await res.text(); if (!res.ok) { console.log( JSON.stringify({ msg: "lolschedule_fetch", status: res.status, body: text.slice(0, 500) }), ); - throw new Error(`Leaguepedia API HTTP ${res.status}`); + throw new Error(`lolesports API HTTP ${res.status}`); } let json; try { json = JSON.parse(text); } catch { - console.log( - JSON.stringify({ msg: "lolschedule_parse_fail", body: text.slice(0, 500) }), - ); - throw new Error("Leaguepedia non-JSON response"); + throw new Error("lolesports non-JSON response"); } - if (json?.error) { - console.log( - JSON.stringify({ - msg: "lolschedule_api_error", - code: json.error.code, - info: json.error.info, - }), - ); - throw new Error(`Leaguepedia error: ${json.error.info || json.error.code}`); - } - const rows = (json?.cargoquery || []).map((r) => r.title); - console.log(JSON.stringify({ msg: "lolschedule_fetch_ok", rows: rows.length })); - return rows; -} - -/** Format a JS Date as Leaguepedia's UTC literal: `YYYY-MM-DD HH:MM:SS`. */ -function toUtcLiteral(date) { - const pad = (n) => String(n).padStart(2, "0"); - return ( - `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ` + - `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}` - ); + const schedule = json?.data?.schedule; + const events = Array.isArray(schedule?.events) ? schedule.events : []; + const filtered = events.filter((e) => e?.type !== "show"); // drop pre/post shows + return { + events: /** @type {ScheduleEvent[]} */ (filtered), + olderToken: schedule?.pages?.older, + newerToken: schedule?.pages?.newer, + }; } /** - * Fetch matches with DateTime_UTC in [from, to). + * Fetch enough pages forward in time to cover the given UTC window. + * Default page returns ~20 events; week view usually needs 1 extra page. * * @param {Date} from * @param {Date} to - * @returns {Promise} + * @param {number} [maxPages] — hard cap to keep it bounded. + * @returns {Promise} */ -export async function fetchMatchesInRange(from, to) { - const fromLit = toUtcLiteral(from); - const toLit = toUtcLiteral(to); - const rows = await cargoQuery({ - tables: "MatchSchedule=MS", - fields: - "MS.DateTime_UTC=DateTime," + - "MS.Team1=T1,MS.Team2=T2," + - "MS.Team1Score=S1,MS.Team2Score=S2," + - "MS.Winner=Winner,MS.Tournament=Tournament," + - "MS.BestOf=BO,MS.OverviewPage=OP", - where: `MS.DateTime_UTC >= "${fromLit}" AND MS.DateTime_UTC < "${toLit}"`, - order_by: "MS.DateTime_UTC ASC", - limit: "100", +export async function fetchEventsInRange(from, to, maxPages = 3) { + const fromMs = from.getTime(); + const toMs = to.getTime(); + /** @type {ScheduleEvent[]} */ + const collected = []; + let pageToken; + for (let i = 0; i < maxPages; i++) { + const { events, newerToken } = await fetchSchedulePage({ pageToken }); + collected.push(...events); + // If the latest event in the page is already past our window end, stop. + const lastMs = events.length ? Date.parse(events[events.length - 1].startTime) : null; + if (lastMs !== null && lastMs >= toMs) break; + if (!newerToken) break; + pageToken = newerToken; + } + return collected.filter((e) => { + const t = Date.parse(e.startTime); + return t >= fromMs && t < toMs; }); - return /** @type {MatchRow[]} */ (rows); +} + +/** Build the KV cache key for a date range. */ +function cacheKey(from, to) { + return `matches:${from.toISOString()}:${to.toISOString()}`; } /** - * Cache-first match lookup keyed by date range. Returns cached rows without - * refetch within TTL; on fetch failure, returns stale cache if available. + * Cache-first lookup. Returns fresh cache within TTL, else tries upstream, + * else returns stale cache up to {@link STALE_MAX_AGE_SEC}, else throws. * * @param {import("../../db/kv-store-interface.js").KVStore} db * @param {Date} from * @param {Date} to - * @param {number} ttlSec - * @returns {Promise} + * @returns {Promise} */ -export async function getCachedMatches(db, from, to, ttlSec) { - const key = `matches:${from.toISOString()}:${to.toISOString()}`; +export async function getEventsCached(db, from, to) { + const key = cacheKey(from, to); const cached = await db.getJSON(key); - if (cached?.ts && Date.now() - cached.ts < ttlSec * 1000) { - return cached.rows; + if (cached?.ts && Date.now() - cached.ts < CACHE_TTL_SEC * 1000) { + return cached.events; } try { - const rows = await fetchMatchesInRange(from, to); + const events = await fetchEventsInRange(from, to); try { - await db.putJSON(key, { ts: Date.now(), rows }, { expirationTtl: ttlSec * 4 }); + await db.putJSON(key, { ts: Date.now(), events }, { expirationTtl: STALE_MAX_AGE_SEC }); } catch (err) { - console.warn("lolschedule: KV putJSON failed", String(err)); + console.log(JSON.stringify({ msg: "lolschedule_kv_put_fail", err: String(err) })); } - return rows; + return events; } catch (err) { - if (cached?.rows) return cached.rows; // stale fallback + if (cached?.events && cached?.ts && Date.now() - cached.ts < STALE_MAX_AGE_SEC * 1000) { + console.log(JSON.stringify({ msg: "lolschedule_stale_fallback", err: String(err) })); + return cached.events; + } throw err; } } -export { CACHE_TTL_TODAY_SEC, CACHE_TTL_WEEK_SEC }; +export { CACHE_TTL_SEC, STALE_MAX_AGE_SEC }; diff --git a/src/modules/lolschedule/format.js b/src/modules/lolschedule/format.js index 109cd8b..d34b4e5 100644 --- a/src/modules/lolschedule/format.js +++ b/src/modules/lolschedule/format.js @@ -1,36 +1,32 @@ /** * @file Pure formatters — Telegram HTML output for today / week match lists. * - * All user-influenced substrings are escaped; match rows come from the public - * wiki so we treat them as untrusted. Times are rendered in ICT (UTC+7) since - * the bot's primary audience is VN; feel free to branch by chat locale later. + * Takes lolesports.com schedule events and renders them. All user-influenced + * substrings are HTML-escaped. Times are shown in ICT (UTC+7). */ import { escapeHtml } from "../../util/escape-html.js"; -/** @typedef {import("./api-client.js").MatchRow} MatchRow */ +/** @typedef {import("./api-client.js").ScheduleEvent} ScheduleEvent */ +/** @typedef {import("./api-client.js").Team} Team */ const TZ_OFFSET_MS = 7 * 60 * 60 * 1000; // ICT = UTC+7 -/** Parse Leaguepedia's `YYYY-MM-DD HH:MM:SS` UTC literal into a Date. */ -export function parseUtc(literal) { - return new Date(`${literal.replace(" ", "T")}Z`); -} - -/** Shift a UTC date by the ICT offset so getUTC* methods yield ICT components. */ +/** Shift a UTC Date by the ICT offset so getUTC* yields ICT components. */ function toIct(date) { return new Date(date.getTime() + TZ_OFFSET_MS); } -/** Format ICT time as `HH:MM`. */ -function formatIctTime(date) { +function pad(n) { + return String(n).padStart(2, "0"); +} + +export function formatIctTime(date) { const d = toIct(date); - const pad = (n) => String(n).padStart(2, "0"); return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; } -/** Format ICT date as `Mon Apr 21` (weekday + month + day). */ -function formatIctDayLabel(date) { +export function formatIctDayLabel(date) { const d = toIct(date); const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const months = [ @@ -50,116 +46,93 @@ function formatIctDayLabel(date) { return `${weekdays[d.getUTCDay()]} ${months[d.getUTCMonth()]} ${d.getUTCDate()}`; } -/** ICT calendar-day key `YYYY-MM-DD` (used for grouping). */ function ictDayKey(date) { const d = toIct(date); - const pad = (n) => String(n).padStart(2, "0"); return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; } -/** Coerce Leaguepedia's string score into a number, or null. */ -function parseScore(s) { - if (s == null || s === "") return null; - const n = Number(s); - return Number.isFinite(n) ? n : null; +/** Pick the visible short tag for a team. */ +function teamLabel(team) { + if (!team) return "TBD"; + return team.code || team.name || "TBD"; } /** - * Classify a match row's display state. + * Render one event line (no leading newline). * - * @param {MatchRow} row - * @param {number} nowMs — injected for testability. - * @returns {"played"|"live"|"scheduled"} + * @param {ScheduleEvent} event + * @returns {string} escaped HTML */ -export function classifyMatch(row, nowMs = Date.now()) { - const startMs = parseUtc(row.DateTime).getTime(); - const winner = String(row.Winner ?? ""); - if (winner === "1" || winner === "2") return "played"; - const s1 = parseScore(row.S1); - const s2 = parseScore(row.S2); - const hasScore = (s1 ?? 0) + (s2 ?? 0) > 0; - if (startMs <= nowMs && hasScore) return "live"; - return "scheduled"; +export function formatEventLine(event) { + 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}` : ""; + + 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}`; + } + 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}`; + } + // unstarted or unknown + const time = formatIctTime(new Date(event.startTime)); + return `🕒 ${time} ${t1Label} vs ${t2Label}${bo} · ${league}${block}`; } /** - * Render one match line (no leading newline). + * Render today's reply. * - * @param {MatchRow} row - * @param {number} [nowMs] - * @returns {string} HTML — already escaped - */ -export function formatMatchLine(row, nowMs = Date.now()) { - const t1 = escapeHtml(row.T1 || "TBD"); - const t2 = escapeHtml(row.T2 || "TBD"); - const tournament = escapeHtml(row.Tournament || ""); - const bo = row.BO ? ` · Bo${escapeHtml(row.BO)}` : ""; - const state = classifyMatch(row, nowMs); - - if (state === "played") { - const s1 = parseScore(row.S1) ?? 0; - const s2 = parseScore(row.S2) ?? 0; - const w1 = String(row.Winner) === "1" ? "" : ""; - const w1c = w1 ? "" : ""; - const w2 = String(row.Winner) === "2" ? "" : ""; - const w2c = w2 ? "" : ""; - return `✅ ${w1}${t1}${w1c} ${s1}–${s2} ${w2}${t2}${w2c}${bo} · ${tournament}`; - } - if (state === "live") { - const s1 = parseScore(row.S1) ?? 0; - const s2 = parseScore(row.S2) ?? 0; - return `🔴 LIVE ${t1} ${s1}–${s2} ${t2}${bo} · ${tournament}`; - } - const time = formatIctTime(parseUtc(row.DateTime)); - return `🕒 ${time} ${t1} vs ${t2}${bo} · ${tournament}`; -} - -/** - * Render the "today" command reply. - * - * @param {MatchRow[]} rows + * @param {ScheduleEvent[]} events * @param {Date} day — any moment on the target ICT day. - * @param {number} [nowMs] * @returns {string} */ -export function renderToday(rows, day, nowMs = Date.now()) { +export function renderToday(events, day) { const header = `LoL — ${escapeHtml(formatIctDayLabel(day))} (ICT)`; - if (rows.length === 0) return `${header}\nNo matches today.`; - return `${header}\n${rows.map((r) => formatMatchLine(r, nowMs)).join("\n")}`; + if (events.length === 0) return `${header}\nNo matches today.`; + return `${header}\n${events.map(formatEventLine).join("\n")}`; } /** - * Render the "week" command reply — matches grouped by ICT day. + * Render week reply — grouped by ICT day. * - * @param {MatchRow[]} rows + * @param {ScheduleEvent[]} events * @param {Date} from * @param {Date} to - * @param {number} [nowMs] * @returns {string} */ -export function renderWeek(rows, from, to, nowMs = Date.now()) { +export function renderWeek(events, from, to) { const fromLbl = escapeHtml(formatIctDayLabel(from)); - // `to` is exclusive; subtract 1 ms for a friendlier "through" label. const toLbl = escapeHtml(formatIctDayLabel(new Date(to.getTime() - 1))); const header = `LoL — ${fromLbl} → ${toLbl} (ICT)`; - if (rows.length === 0) return `${header}\nNo matches this week.`; + if (events.length === 0) return `${header}\nNo matches this week.`; /** @type {Map} */ const groups = new Map(); - for (const row of rows) { - const d = parseUtc(row.DateTime); + for (const event of events) { + const d = new Date(event.startTime); const key = ictDayKey(d); let g = groups.get(key); if (!g) { g = { label: formatIctDayLabel(d), lines: [] }; groups.set(key, g); } - g.lines.push(formatMatchLine(row, nowMs)); + g.lines.push(formatEventLine(event)); } const sections = []; - const sortedKeys = [...groups.keys()].sort(); - for (const key of sortedKeys) { + for (const key of [...groups.keys()].sort()) { const g = groups.get(key); sections.push(`${escapeHtml(g.label)}\n${g.lines.join("\n")}`); } diff --git a/src/modules/lolschedule/handlers.js b/src/modules/lolschedule/handlers.js index 14cfbd1..fe6ab44 100644 --- a/src/modules/lolschedule/handlers.js +++ b/src/modules/lolschedule/handlers.js @@ -1,20 +1,37 @@ /** * @file /lol_today and /lol_week command handlers. * - * Day boundaries are defined in ICT (UTC+7), converted to the UTC literals - * that Leaguepedia's cargo query expects. Errors are surfaced as a short reply - * — we never throw through grammY. + * 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. */ -import { CACHE_TTL_TODAY_SEC, CACHE_TTL_WEEK_SEC, getCachedMatches } from "./api-client.js"; +import { getEventsCached } from "./api-client.js"; import { renderToday, renderWeek } from "./format.js"; const ICT_OFFSET_MS = 7 * 60 * 60 * 1000; -/** - * Start of the current ICT calendar day, expressed as a UTC `Date`. - * @param {number} [nowMs] - */ +// Top-tier league allowlist. The API returns every regional/academy league +// (135+ events/week); filtering keeps the reply under Telegram's 4096-char +// limit and focuses on what most viewers care about. +const MAJOR_LEAGUE_SLUGS = new Set([ + "lck", + "lpl", + "lec", + "lcs", + "worlds", + "msi", + "first_stand", + "lcp", + "cblol-brazil", + "emea_masters", +]); + +function filterMajor(events) { + return events.filter((e) => MAJOR_LEAGUE_SLUGS.has(e?.league?.slug)); +} + +/** Start of the current ICT calendar day, expressed as a UTC `Date`. */ export function ictDayStart(nowMs = Date.now()) { const shifted = new Date(nowMs + ICT_OFFSET_MS); shifted.setUTCHours(0, 0, 0, 0); @@ -38,12 +55,10 @@ export async function handleToday(ctx, db) { const from = ictDayStart(); const to = addDays(from, 1); try { - const rows = await getCachedMatches(db, from, to, CACHE_TTL_TODAY_SEC); - await ctx.reply(renderToday(rows, from), { parse_mode: "HTML" }); + const events = filterMajor(await getEventsCached(db, from, to)); + await ctx.reply(renderToday(events, from), { parse_mode: "HTML" }); } catch (err) { - console.log( - JSON.stringify({ msg: "lolschedule_today_fail", err: String(err) }), - ); + console.log(JSON.stringify({ msg: "lolschedule_today_fail", err: String(err) })); await ctx.reply("Could not fetch today's matches. Try again later."); } } @@ -60,12 +75,10 @@ export async function handleWeek(ctx, db) { const from = ictDayStart(); const to = addDays(from, 7); try { - const rows = await getCachedMatches(db, from, to, CACHE_TTL_WEEK_SEC); - await ctx.reply(renderWeek(rows, from, to), { parse_mode: "HTML" }); + const events = filterMajor(await getEventsCached(db, from, to)); + await ctx.reply(renderWeek(events, from, to), { parse_mode: "HTML" }); } catch (err) { - console.log( - JSON.stringify({ msg: "lolschedule_week_fail", err: String(err) }), - ); + console.log(JSON.stringify({ msg: "lolschedule_week_fail", err: String(err) })); await ctx.reply("Could not fetch this week's matches. Try again later."); } } diff --git a/tests/modules/lolschedule/api-client.test.js b/tests/modules/lolschedule/api-client.test.js index 9638294..c4be106 100644 --- a/tests/modules/lolschedule/api-client.test.js +++ b/tests/modules/lolschedule/api-client.test.js @@ -1,119 +1,141 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createStore } from "../../../src/db/create-store.js"; import { - fetchMatchesInRange, - getCachedMatches, + fetchEventsInRange, + fetchSchedulePage, + getEventsCached, } from "../../../src/modules/lolschedule/api-client.js"; import { makeFakeKv } from "../../fakes/fake-kv-namespace.js"; -function cargoResponse(rows) { - const payload = { cargoquery: rows.map((title) => ({ title })) }; +function scheduleResponse(events, { newer, older } = {}) { + const payload = { + data: { schedule: { events, pages: { newer, older } } }, + }; return { ok: true, status: 200, text: async () => JSON.stringify(payload), - json: async () => payload, }; } -const sampleRow = { - DateTime: "2026-04-21 09:00:00", - T1: "T1", - T2: "Gen.G", - S1: "", - S2: "", - Winner: "", - Tournament: "LCK 2026 Spring", - BO: "3", - OP: "LCK/2026_Season/Spring_Season", -}; +function evt(startTime, state = "unstarted") { + return { + startTime, + state, + type: "match", + blockName: "W1", + league: { name: "LCK", slug: "lck" }, + match: { + id: `m-${startTime}`, + teams: [{ code: "T1" }, { code: "GEN" }], + strategy: { type: "bestOf", count: 3 }, + }, + }; +} -describe("fetchMatchesInRange", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); +describe("fetchSchedulePage", () => { + afterEach(() => vi.restoreAllMocks()); - it("posts cargoquery with correct where clause and parses rows", async () => { - const fetchSpy = vi.fn().mockResolvedValue(cargoResponse([sampleRow])); - global.fetch = fetchSpy; + it("calls the getSchedule endpoint with the public API key", async () => { + const spy = vi.fn().mockResolvedValue(scheduleResponse([evt("2026-04-21T09:00:00Z")])); + global.fetch = spy; - const from = new Date("2026-04-21T00:00:00Z"); - const to = new Date("2026-04-22T00:00:00Z"); - const rows = await fetchMatchesInRange(from, to); - - expect(rows).toEqual([sampleRow]); - expect(fetchSpy).toHaveBeenCalledOnce(); - const [url, opts] = fetchSpy.mock.calls[0]; - expect(url).toBe("https://lol.fandom.com/api.php"); - expect(opts.method).toBe("POST"); + const { events } = await fetchSchedulePage(); + expect(events).toHaveLength(1); + const [url, opts] = spy.mock.calls[0]; + expect(url).toContain("esports-api.lolesports.com/persisted/gw/getSchedule"); + expect(url).toContain("hl=en-US"); + expect(opts.headers["x-api-key"]).toBeDefined(); expect(opts.headers["User-Agent"]).toMatch(/miti99bot/); - const body = opts.body.toString(); - expect(body).toContain("action=cargoquery"); - expect(body).toContain("MatchSchedule%3DMS"); // "MatchSchedule=MS" url-encoded - expect(body).toContain("2026-04-21+00%3A00%3A00"); - expect(body).toContain("2026-04-22+00%3A00%3A00"); }); - it("surfaces Leaguepedia API error field", async () => { - const errPayload = { error: { info: "Bad query", code: "x" } }; + it("drops type=show entries (pre/post shows)", async () => { + const show = { ...evt("2026-04-21T07:00:00Z"), type: "show" }; + global.fetch = vi.fn().mockResolvedValue(scheduleResponse([show, evt("2026-04-21T09:00:00Z")])); + const { events } = await fetchSchedulePage(); + expect(events.map((e) => e.type)).not.toContain("show"); + }); + + it("surfaces non-2xx responses as errors", async () => { global.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - text: async () => JSON.stringify(errPayload), - json: async () => errPayload, + ok: false, + status: 403, + text: async () => "forbidden", }); - await expect( - fetchMatchesInRange(new Date("2026-04-21T00:00:00Z"), new Date("2026-04-22T00:00:00Z")), - ).rejects.toThrow(/Bad query/); + await expect(fetchSchedulePage()).rejects.toThrow(/HTTP 403/); }); }); -describe("getCachedMatches", () => { +describe("fetchEventsInRange", () => { + afterEach(() => vi.restoreAllMocks()); + + it("filters events by startTime in [from, to)", async () => { + global.fetch = vi.fn().mockResolvedValue( + scheduleResponse( + [ + evt("2026-04-20T09:00:00Z"), // yesterday + evt("2026-04-21T09:00:00Z"), // today + evt("2026-04-22T09:00:00Z"), // tomorrow — outside 1-day window + ], + { newer: null }, + ), + ); + const out = await fetchEventsInRange( + new Date("2026-04-21T00:00:00Z"), + new Date("2026-04-22T00:00:00Z"), + ); + expect(out.map((e) => e.startTime)).toEqual(["2026-04-21T09:00:00Z"]); + }); + + it("pages forward when the last event is before window end and a newer token exists", async () => { + const spy = vi + .fn() + .mockResolvedValueOnce(scheduleResponse([evt("2026-04-21T09:00:00Z")], { newer: "tok1" })) + .mockResolvedValueOnce(scheduleResponse([evt("2026-04-25T09:00:00Z")], { newer: null })); + global.fetch = spy; + const out = await fetchEventsInRange( + new Date("2026-04-21T00:00:00Z"), + new Date("2026-04-28T00:00:00Z"), + ); + expect(spy).toHaveBeenCalledTimes(2); + expect(out).toHaveLength(2); + }); +}); + +describe("getEventsCached", () => { let db; - let kv; - beforeEach(() => { - kv = makeFakeKv(); - db = createStore("lolschedule", { KV: kv }); + db = createStore("lolschedule", { KV: makeFakeKv() }); + }); + afterEach(() => vi.restoreAllMocks()); + + const from = new Date("2026-04-21T00:00:00Z"); + const to = new Date("2026-04-22T00:00:00Z"); + + it("caches a successful fetch then serves from cache", async () => { + const spy = vi + .fn() + .mockResolvedValue(scheduleResponse([evt("2026-04-21T09:00:00Z")], { newer: null })); + global.fetch = spy; + const r1 = await getEventsCached(db, from, to); + const r2 = await getEventsCached(db, from, to); + expect(r1).toHaveLength(1); + expect(r2).toHaveLength(1); + expect(spy).toHaveBeenCalledOnce(); }); - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("caches a successful fetch and returns cached on the second call", async () => { - const fetchSpy = vi.fn().mockResolvedValue(cargoResponse([sampleRow])); - global.fetch = fetchSpy; - - const from = new Date("2026-04-21T00:00:00Z"); - const to = new Date("2026-04-22T00:00:00Z"); - const r1 = await getCachedMatches(db, from, to, 60); - const r2 = await getCachedMatches(db, from, to, 60); - - expect(r1).toEqual([sampleRow]); - expect(r2).toEqual([sampleRow]); - expect(fetchSpy).toHaveBeenCalledOnce(); - }); - - it("falls back to stale cache when the fetch fails", async () => { - const from = new Date("2026-04-21T00:00:00Z"); - const to = new Date("2026-04-22T00:00:00Z"); - - // Seed the cache with stale data directly. + it("falls back to stale cache if the fetch fails and cache is within STALE_MAX_AGE_SEC", async () => { await db.putJSON(`matches:${from.toISOString()}:${to.toISOString()}`, { - ts: Date.now() - 10 * 60 * 1000, // 10 min ago — older than the 60s TTL - rows: [sampleRow], + ts: Date.now() - 10 * 60 * 1000, // 10 min ago, older than 120s TTL, newer than 1h stale max + events: [evt("2026-04-21T09:00:00Z")], }); - global.fetch = vi.fn().mockRejectedValue(new Error("boom")); - const out = await getCachedMatches(db, from, to, 60); - expect(out).toEqual([sampleRow]); + const out = await getEventsCached(db, from, to); + expect(out).toHaveLength(1); }); - it("propagates the error when no cache is available", async () => { + it("throws when fetch fails and no cache is available", async () => { global.fetch = vi.fn().mockRejectedValue(new Error("boom")); - await expect( - getCachedMatches(db, new Date("2026-04-21T00:00:00Z"), new Date("2026-04-22T00:00:00Z"), 60), - ).rejects.toThrow(/boom/); + await expect(getEventsCached(db, from, to)).rejects.toThrow(/boom/); }); }); diff --git a/tests/modules/lolschedule/format.test.js b/tests/modules/lolschedule/format.test.js index 4528921..eeea79f 100644 --- a/tests/modules/lolschedule/format.test.js +++ b/tests/modules/lolschedule/format.test.js @@ -1,145 +1,148 @@ import { describe, expect, it } from "vitest"; import { - classifyMatch, - formatMatchLine, - parseUtc, + formatEventLine, + formatIctDayLabel, + formatIctTime, renderToday, renderWeek, } from "../../../src/modules/lolschedule/format.js"; -const scheduled = { - DateTime: "2026-04-21 09:00:00", - T1: "T1", - T2: "Gen.G", - S1: "", - S2: "", - Winner: "", - Tournament: "LCK 2026 Spring", - BO: "3", - OP: "LCK/2026_Season/Spring_Season", -}; +/** @typedef {import("../../../src/modules/lolschedule/api-client.js").ScheduleEvent} ScheduleEvent */ -const live = { - ...scheduled, - DateTime: "2026-04-21 08:00:00", - S1: "1", - S2: "0", -}; +const completed = /** @type {ScheduleEvent} */ ({ + startTime: "2026-04-20T08:00:00Z", + state: "completed", + blockName: "Week 3", + league: { name: "LCK", slug: "lck" }, + match: { + id: "1", + teams: [ + { name: "T1", code: "T1", result: { outcome: "win", gameWins: 2 } }, + { name: "Gen.G", code: "GEN", result: { outcome: "loss", gameWins: 1 } }, + ], + strategy: { type: "bestOf", count: 3 }, + }, +}); -const played = { - ...scheduled, - DateTime: "2026-04-20 10:00:00", - S1: "2", - S2: "1", - Winner: "1", -}; +const live = /** @type {ScheduleEvent} */ ({ + startTime: "2026-04-21T08:00:00Z", + state: "inProgress", + blockName: "Week 4", + league: { name: "LCK", slug: "lck" }, + match: { + id: "2", + teams: [ + { name: "Hanwha Life", code: "HLE", result: { gameWins: 1 } }, + { name: "DRX", code: "DRX", result: { gameWins: 0 } }, + ], + strategy: { type: "bestOf", count: 3 }, + }, +}); -// Fixed reference — 2026-04-21 08:30:00 UTC (i.e. 15:30 ICT) -const nowMs = Date.parse("2026-04-21T08:30:00Z"); +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("parseUtc", () => { - it("parses Leaguepedia UTC literal", () => { - expect(parseUtc("2026-04-21 09:00:00").toISOString()).toBe("2026-04-21T09:00:00.000Z"); +describe("formatIctTime / formatIctDayLabel", () => { + it("formats UTC datetime in ICT", () => { + expect(formatIctTime(new Date("2026-04-21T09:00:00Z"))).toBe("16:00"); + expect(formatIctDayLabel(new Date("2026-04-21T00:00:00Z"))).toBe("Tue Apr 21"); }); }); -describe("classifyMatch", () => { - it("returns played when Winner is 1 or 2", () => { - expect(classifyMatch(played, nowMs)).toBe("played"); - expect(classifyMatch({ ...played, Winner: "2" }, nowMs)).toBe("played"); - }); - - it("returns live when started, has partial score, no winner", () => { - expect(classifyMatch(live, nowMs)).toBe("live"); - }); - - it("returns scheduled when start time is in the future", () => { - expect(classifyMatch(scheduled, nowMs)).toBe("scheduled"); - }); - - it("returns scheduled when start is past but no score", () => { - const row = { ...scheduled, DateTime: "2026-04-21 07:00:00" }; - expect(classifyMatch(row, nowMs)).toBe("scheduled"); - }); -}); - -describe("formatMatchLine", () => { - it("renders scheduled with ICT time and vs", () => { - const line = formatMatchLine(scheduled, nowMs); - // 09:00 UTC → 16:00 ICT - expect(line).toContain("16:00"); - expect(line).toContain("vs"); - expect(line).toContain("Bo3"); - expect(line).toContain("LCK 2026 Spring"); - expect(line.startsWith("🕒")).toBe(true); - }); - - it("renders played with score and winner bolded", () => { - const line = formatMatchLine(played, nowMs); - expect(line).toContain("2–1"); - expect(line).toContain("T1"); +describe("formatEventLine", () => { + it("renders completed with bolded winner + score", () => { + const line = formatEventLine(completed); expect(line.startsWith("✅")).toBe(true); + expect(line).toContain("T1"); + expect(line).toContain("2–1"); + expect(line).toContain("GEN"); + expect(line).toContain("Bo3"); + expect(line).toContain("LCK"); + expect(line).toContain("Week 3"); }); - it("renders live prefix", () => { - const line = formatMatchLine(live, nowMs); + it("renders live with LIVE prefix + current score", () => { + const line = formatEventLine(live); expect(line.startsWith("🔴 LIVE")).toBe(true); expect(line).toContain("1–0"); }); - it("escapes HTML in team and tournament names", () => { - const row = { ...scheduled, T1: "