mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-16 12:21:51 +00:00
feat(lolschedule): add /lolschedule [date] command
Accepts dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy with trailing month/year optional. Empty defaults to today's ICT day. Rejects month/year without day, invalid ranges, and impossible dates.
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
|
||||
import { getEventsCached } from "./api-client.js";
|
||||
import { renderToday, renderWeek } from "./format.js";
|
||||
import { parseScheduleDate } from "./parse-date.js";
|
||||
import { addSubscriber, listSubscribers, removeSubscriber } from "./subscribers.js";
|
||||
|
||||
const ICT_OFFSET_MS = 7 * 60 * 60 * 1000;
|
||||
@@ -65,6 +66,35 @@ export async function handleToday(ctx, db) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* /lolschedule [date] — schedule for a specific ICT day. Accepts dd-mm-yyyy,
|
||||
* dd/mm/yyyy, or ddmmyyyy with trailing month/year optional. Empty → today.
|
||||
*
|
||||
* @param {import("grammy").Context} ctx
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore | null} db
|
||||
*/
|
||||
export async function handleSchedule(ctx, db) {
|
||||
if (!db) {
|
||||
await ctx.reply("lolschedule: storage unavailable");
|
||||
return;
|
||||
}
|
||||
const arg = (ctx.match || "").trim();
|
||||
const parsed = parseScheduleDate(arg);
|
||||
if (!parsed.ok) {
|
||||
await ctx.reply(parsed.error);
|
||||
return;
|
||||
}
|
||||
const from = parsed.date;
|
||||
const to = addDays(from, 1);
|
||||
try {
|
||||
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_fail", err: String(err) }));
|
||||
await ctx.reply("Could not fetch matches. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("grammy").Context} ctx
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore | null} db
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
* lolesports.com esports-api (the data feed behind lolesports.com).
|
||||
*
|
||||
* Commands:
|
||||
* /lolschedule [date] — matches for a specific ICT day (defaults to today).
|
||||
* Accepts dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy; trailing
|
||||
* month/year may be omitted.
|
||||
* /lolschedule_today — matches scheduled for the current ICT day, with live/played scores.
|
||||
* /lolschedule_week — next 7 ICT days, grouped per day → league.
|
||||
*
|
||||
@@ -16,6 +19,7 @@
|
||||
|
||||
import {
|
||||
handleDailyPushCron,
|
||||
handleSchedule,
|
||||
handleSubscribe,
|
||||
handleToday,
|
||||
handleUnsubscribe,
|
||||
@@ -32,6 +36,12 @@ const lolscheduleModule = {
|
||||
db = store;
|
||||
},
|
||||
commands: [
|
||||
{
|
||||
name: "lolschedule",
|
||||
visibility: "public",
|
||||
description: "LoL matches for a date (dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy; default today)",
|
||||
handler: (ctx) => handleSchedule(ctx, db),
|
||||
},
|
||||
{
|
||||
name: "lolschedule_today",
|
||||
visibility: "public",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file Parse a user-supplied date param for /lolschedule.
|
||||
*
|
||||
* Supported formats: dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy. Trailing components
|
||||
* may be omitted (year, then month) — falling back to the current ICT day.
|
||||
* Empty input → today. Day is mandatory; month/year without day is rejected.
|
||||
*/
|
||||
|
||||
const ICT_OFFSET_MS = 7 * 60 * 60 * 1000;
|
||||
|
||||
const FORMAT_HINT = "Use dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy.";
|
||||
|
||||
function toIct(date) {
|
||||
return new Date(date.getTime() + ICT_OFFSET_MS);
|
||||
}
|
||||
|
||||
function ictDayStartOf(now) {
|
||||
const shifted = toIct(now);
|
||||
shifted.setUTCHours(0, 0, 0, 0);
|
||||
return new Date(shifted.getTime() - ICT_OFFSET_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the raw input into [dd, mm?, yyyy?] string parts, or return an error.
|
||||
*
|
||||
* @param {string} trimmed
|
||||
* @returns {{ ok: true, parts: string[] } | { ok: false, error: string }}
|
||||
*/
|
||||
function splitParts(trimmed) {
|
||||
if (trimmed.includes("-") || trimmed.includes("/")) {
|
||||
const parts = trimmed.split(/[-/]/);
|
||||
if (parts.length < 1 || parts.length > 3) {
|
||||
return { ok: false, error: `Invalid date "${trimmed}". ${FORMAT_HINT}` };
|
||||
}
|
||||
if (parts.some((p) => p === "" || !/^\d+$/.test(p))) {
|
||||
return { ok: false, error: `Invalid date "${trimmed}". ${FORMAT_HINT}` };
|
||||
}
|
||||
return { ok: true, parts };
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return { ok: false, error: `Invalid date "${trimmed}". ${FORMAT_HINT}` };
|
||||
}
|
||||
if (trimmed.length === 1 || trimmed.length === 2) {
|
||||
return { ok: true, parts: [trimmed] };
|
||||
}
|
||||
if (trimmed.length === 4) {
|
||||
return { ok: true, parts: [trimmed.slice(0, 2), trimmed.slice(2)] };
|
||||
}
|
||||
if (trimmed.length === 8) {
|
||||
return {
|
||||
ok: true,
|
||||
parts: [trimmed.slice(0, 2), trimmed.slice(2, 4), trimmed.slice(4)],
|
||||
};
|
||||
}
|
||||
return { ok: false, error: `Invalid date "${trimmed}". ${FORMAT_HINT}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a user date param into the start of the requested ICT day.
|
||||
*
|
||||
* @param {string|undefined|null} input
|
||||
* @param {Date} [now]
|
||||
* @returns {{ ok: true, date: Date } | { ok: false, error: string }}
|
||||
*/
|
||||
export function parseScheduleDate(input, now = new Date()) {
|
||||
const trimmed = (input ?? "").trim();
|
||||
if (!trimmed) return { ok: true, date: ictDayStartOf(now) };
|
||||
|
||||
const split = splitParts(trimmed);
|
||||
if (!split.ok) return split;
|
||||
|
||||
const ictNow = toIct(now);
|
||||
const day = Number(split.parts[0]);
|
||||
const month = split.parts.length >= 2 ? Number(split.parts[1]) : ictNow.getUTCMonth() + 1;
|
||||
const year = split.parts.length >= 3 ? Number(split.parts[2]) : ictNow.getUTCFullYear();
|
||||
|
||||
if (!Number.isInteger(day) || day < 1 || day > 31) {
|
||||
return { ok: false, error: `Invalid day "${split.parts[0]}" — must be 1–31.` };
|
||||
}
|
||||
if (!Number.isInteger(month) || month < 1 || month > 12) {
|
||||
return { ok: false, error: `Invalid month "${split.parts[1]}" — must be 1–12.` };
|
||||
}
|
||||
if (!Number.isInteger(year) || year < 1970 || year > 2100) {
|
||||
return { ok: false, error: `Invalid year "${split.parts[2]}".` };
|
||||
}
|
||||
|
||||
// ICT midnight of requested day, expressed as a UTC instant.
|
||||
const utcMs = Date.UTC(year, month - 1, day, 0, 0, 0) - ICT_OFFSET_MS;
|
||||
const date = new Date(utcMs);
|
||||
|
||||
// Reject impossible dates like 31-04 or 29-02 in non-leap years.
|
||||
const verified = toIct(date);
|
||||
if (
|
||||
verified.getUTCFullYear() !== year ||
|
||||
verified.getUTCMonth() + 1 !== month ||
|
||||
verified.getUTCDate() !== day
|
||||
) {
|
||||
return { ok: false, error: `Invalid date — ${day}/${month}/${year} does not exist.` };
|
||||
}
|
||||
|
||||
return { ok: true, date };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStore } from "../../../src/db/create-store.js";
|
||||
import {
|
||||
handleDailyPushCron,
|
||||
handleSchedule,
|
||||
handleSubscribe,
|
||||
handleUnsubscribe,
|
||||
} from "../../../src/modules/lolschedule/handlers.js";
|
||||
@@ -81,6 +82,48 @@ describe("handleSubscribe / handleUnsubscribe", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleSchedule", () => {
|
||||
let db;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createStore("lolschedule", { KV: makeFakeKv() });
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("replies with a parse error on a bad date param", async () => {
|
||||
const ctx = { ...fakeCtx(1), match: "abc" };
|
||||
await handleSchedule(ctx, db);
|
||||
expect(ctx.replied[0].text).toMatch(/Invalid date/);
|
||||
});
|
||||
|
||||
it("rejects a 6-digit blob (month+year without day)", async () => {
|
||||
const ctx = { ...fakeCtx(1), match: "052026" };
|
||||
await handleSchedule(ctx, db);
|
||||
expect(ctx.replied[0].text).toMatch(/Invalid date/);
|
||||
});
|
||||
|
||||
it("fetches the requested ICT day when input parses", async () => {
|
||||
global.fetch = vi.fn(async () => scheduleResponse([majorEvt("2025-06-01T09:00:00Z")]));
|
||||
const ctx = { ...fakeCtx(1), match: "01-06-2025" };
|
||||
await handleSchedule(ctx, db);
|
||||
expect(ctx.replied[0].text).toMatch(/LCK/);
|
||||
expect(ctx.replied[0].opts).toEqual({ parse_mode: "HTML" });
|
||||
});
|
||||
|
||||
it("defaults to today when input is empty", async () => {
|
||||
const ictOffsetMs = 7 * 60 * 60 * 1000;
|
||||
const ictDayStart = new Date(
|
||||
new Date(Date.now() + ictOffsetMs).setUTCHours(0, 0, 0, 0) - ictOffsetMs,
|
||||
);
|
||||
const pickTime = new Date(ictDayStart.getTime() + 12 * 60 * 60 * 1000).toISOString();
|
||||
global.fetch = vi.fn(async () => scheduleResponse([majorEvt(pickTime)]));
|
||||
const ctx = { ...fakeCtx(1), match: "" };
|
||||
await handleSchedule(ctx, db);
|
||||
expect(ctx.replied[0].text).toMatch(/LCK/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDailyPushCron", () => {
|
||||
let db;
|
||||
let telegramSpy;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseScheduleDate } from "../../../src/modules/lolschedule/parse-date.js";
|
||||
|
||||
const ICT_OFFSET_MS = 7 * 60 * 60 * 1000;
|
||||
|
||||
/** Build a Date that represents the start of the given ICT day. */
|
||||
function ictStart(year, month, day) {
|
||||
return new Date(Date.UTC(year, month - 1, day, 0, 0, 0) - ICT_OFFSET_MS);
|
||||
}
|
||||
|
||||
/** Pick a `now` clock anchored at noon ICT on a known day. */
|
||||
function nowAt(year, month, day) {
|
||||
return new Date(Date.UTC(year, month - 1, day, 12, 0, 0) - ICT_OFFSET_MS);
|
||||
}
|
||||
|
||||
describe("parseScheduleDate", () => {
|
||||
const now = nowAt(2026, 5, 8); // 8 May 2026 ICT
|
||||
|
||||
it("returns today's ICT day start when input is empty", () => {
|
||||
const r = parseScheduleDate("", now);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.date.getTime()).toBe(ictStart(2026, 5, 8).getTime());
|
||||
});
|
||||
|
||||
it("returns today when input is whitespace", () => {
|
||||
const r = parseScheduleDate(" ", now);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.date.getTime()).toBe(ictStart(2026, 5, 8).getTime());
|
||||
});
|
||||
|
||||
it("returns today when input is undefined", () => {
|
||||
const r = parseScheduleDate(undefined, now);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.date.getTime()).toBe(ictStart(2026, 5, 8).getTime());
|
||||
});
|
||||
|
||||
it("parses dd-mm-yyyy", () => {
|
||||
const r = parseScheduleDate("01-06-2025", now);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.date.getTime()).toBe(ictStart(2025, 6, 1).getTime());
|
||||
});
|
||||
|
||||
it("parses dd/mm/yyyy", () => {
|
||||
const r = parseScheduleDate("01/06/2025", now);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.date.getTime()).toBe(ictStart(2025, 6, 1).getTime());
|
||||
});
|
||||
|
||||
it("parses ddmmyyyy (no separator)", () => {
|
||||
const r = parseScheduleDate("01062025", now);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.date.getTime()).toBe(ictStart(2025, 6, 1).getTime());
|
||||
});
|
||||
|
||||
it("fills in current year when only dd-mm given", () => {
|
||||
const r = parseScheduleDate("12-09", now);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.date.getTime()).toBe(ictStart(2026, 9, 12).getTime());
|
||||
});
|
||||
|
||||
it("fills in current year when only dd/mm given", () => {
|
||||
const r = parseScheduleDate("12/09", now);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.date.getTime()).toBe(ictStart(2026, 9, 12).getTime());
|
||||
});
|
||||
|
||||
it("fills in current year when only ddmm (4 digits) given", () => {
|
||||
const r = parseScheduleDate("1209", now);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.date.getTime()).toBe(ictStart(2026, 9, 12).getTime());
|
||||
});
|
||||
|
||||
it("fills in current month and year when only dd given (2 digits)", () => {
|
||||
const r = parseScheduleDate("20", now);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.date.getTime()).toBe(ictStart(2026, 5, 20).getTime());
|
||||
});
|
||||
|
||||
it("fills in current month and year when only d given (1 digit)", () => {
|
||||
const r = parseScheduleDate("3", now);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.date.getTime()).toBe(ictStart(2026, 5, 3).getTime());
|
||||
});
|
||||
|
||||
it("rejects a 6-digit blob (mmyyyy with no day)", () => {
|
||||
const r = parseScheduleDate("052026", now);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/Invalid date/);
|
||||
});
|
||||
|
||||
it("rejects a 3-digit blob (ambiguous)", () => {
|
||||
const r = parseScheduleDate("123", now);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/Invalid date/);
|
||||
});
|
||||
|
||||
it("rejects empty leading part with separator (no day)", () => {
|
||||
const r = parseScheduleDate("/05/2026", now);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/Invalid date/);
|
||||
});
|
||||
|
||||
it("rejects empty middle part", () => {
|
||||
const r = parseScheduleDate("12//2026", now);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/Invalid date/);
|
||||
});
|
||||
|
||||
it("rejects non-numeric input", () => {
|
||||
const r = parseScheduleDate("abc", now);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/Invalid date/);
|
||||
});
|
||||
|
||||
it("rejects out-of-range day", () => {
|
||||
const r = parseScheduleDate("32-05-2026", now);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/day/);
|
||||
});
|
||||
|
||||
it("rejects out-of-range month", () => {
|
||||
const r = parseScheduleDate("01-13-2026", now);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/month/);
|
||||
});
|
||||
|
||||
it("rejects impossible calendar dates (Feb 30)", () => {
|
||||
const r = parseScheduleDate("30-02-2025", now);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/does not exist/);
|
||||
});
|
||||
|
||||
it("accepts Feb 29 in a leap year", () => {
|
||||
const r = parseScheduleDate("29-02-2024", now);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.date.getTime()).toBe(ictStart(2024, 2, 29).getTime());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user