refactor(lolschedule): swap Leaguepedia for lolesports.com esports-api

Leaguepedia's anonymous IP rate limit is too aggressive for a bot even
from CF Worker egress (~1–2 req/min), and authenticated Fandom tokens
don't lift it. Switching to the lolesports.com getSchedule endpoint —
the same data feed powering the official site — removes the limit and
provides richer fields: state (unstarted/inProgress/completed), per-team
result.gameWins and outcome, league metadata, bestOf strategy.

Handlers simplify back to cache-first (120 s fresh / 1 h stale fallback)
with no cron needed. Results are filtered to major leagues (LCK, LPL,
LEC, LCS, worlds, msi, first_stand, LCP, CBLOL, EMEA Masters) to keep
the week view under Telegram's 4096-char message limit.
This commit is contained in:
tiennm99 authored and Tien Nguyen Minh committed 2026-04-21 10:11:49 +07:00
1 parent 436664c8a1
commit e10269ca0a
6 files changed
+438 -398

No files matched your search

+40 -19
View File
@@ -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: <public 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
+102 -94
View File
@@ -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<string, string>} params
* @returns {Promise<object[]>} 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<MatchRow[]>}
* @param {number} [maxPages] — hard cap to keep it bounded.
* @returns {Promise<ScheduleEvent[]>}
*/
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<MatchRow[]>}
* @returns {Promise<ScheduleEvent[]>}
*/
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 };
+57 -84
View File
@@ -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 ? `<b>${t1Label}</b>` : t1Label;
const r = winner2 ? `<b>${t2Label}</b>` : 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" ? "<b>" : "";
const w1c = w1 ? "</b>" : "";
const w2 = String(row.Winner) === "2" ? "<b>" : "";
const w2c = w2 ? "</b>" : "";
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 = `<b>LoL — ${escapeHtml(formatIctDayLabel(day))}</b> (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 = `<b>LoL — ${fromLbl} → ${toLbl}</b> (ICT)`;
if (rows.length === 0) return `${header}\nNo matches this week.`;
if (events.length === 0) return `${header}\nNo matches this week.`;
/** @type {Map<string, { label: string, lines: string[] }>} */
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(`<b>${escapeHtml(g.label)}</b>\n${g.lines.join("\n")}`);
}
+31 -18
View File
@@ -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.");
}
}
+105 -83
View File
@@ -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/);
});
});
+103 -100
View File
@@ -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("<b>T1</b>");
describe("formatEventLine", () => {
it("renders completed with bolded winner + score", () => {
const line = formatEventLine(completed);
expect(line.startsWith("✅")).toBe(true);
expect(line).toContain("<b>T1</b>");
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: "<script>", Tournament: "A&B" };
const line = formatMatchLine(row, nowMs);
it("renders unstarted with ICT time + vs", () => {
const line = formatEventLine(scheduled);
expect(line.startsWith("🕒 16:00")).toBe(true);
expect(line).toContain("KT vs DK");
});
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);
expect(line).not.toContain("<script>");
expect(line).toContain("&lt;script&gt;");
expect(line).toContain("&lt;bad&gt;");
expect(line).toContain("A&amp;B");
});
it("shows TBD when team field is empty", () => {
const row = { ...scheduled, T1: "" };
expect(formatMatchLine(row, nowMs)).toContain("TBD");
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");
});
});
describe("renderToday", () => {
it("returns empty-state message when no rows", () => {
const out = renderToday([], new Date("2026-04-21T00:00:00Z"), nowMs);
expect(out).toContain("No matches today.");
it("empty state when no events", () => {
expect(renderToday([], new Date("2026-04-21T00:00:00Z"))).toContain("No matches today.");
});
it("renders header + one line per match", () => {
const out = renderToday([scheduled, played], new Date("2026-04-21T00:00:00Z"), nowMs);
expect(out).toContain("<b>LoL —");
expect(out.split("\n").length).toBeGreaterThanOrEqual(3);
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);
});
});
describe("renderWeek", () => {
it("groups rows by ICT day", () => {
const rows = [
{ ...scheduled, DateTime: "2026-04-21 09:00:00" }, // Apr 21 ICT
{ ...scheduled, DateTime: "2026-04-22 10:00:00" }, // Apr 22 ICT
{ ...scheduled, DateTime: "2026-04-22 12:00:00" }, // Apr 22 ICT
it("groups events by ICT day in order", () => {
const events = [
{ ...scheduled, startTime: "2026-04-21T09:00:00Z" },
{ ...scheduled, startTime: "2026-04-22T10:00:00Z" },
{ ...scheduled, startTime: "2026-04-22T12:00:00Z" },
];
const out = renderWeek(
rows,
events,
new Date("2026-04-21T00:00:00Z"),
new Date("2026-04-28T00:00:00Z"),
nowMs,
);
// Day labels appear once each, in order
expect(out.indexOf("Apr 21")).toBeLessThan(out.indexOf("Apr 22"));
const apr22Matches = out.split("Apr 22")[1] || "";
expect((apr22Matches.match(/🕒/g) || []).length).toBe(2);
const apr22Section = out.split("Apr 22")[1] || "";
expect((apr22Section.match(/🕒/g) || []).length).toBe(2);
});
it("returns empty-state when no matches", () => {
const out = renderWeek(
[],
new Date("2026-04-21T00:00:00Z"),
new Date("2026-04-28T00:00:00Z"),
nowMs,
);
expect(out).toContain("No matches this week.");
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.");
});
});