diff --git a/README.md b/README.md index dfd2aea..43aaffc 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ | 카테고리 | 설명 | | ----------- | ------------------------------------------------------------------------- | -| 🍱 급식 | NEIS API를 통해 오늘/내일 아침·점심·저녁 메뉴 및 칼로리 조회 | +| 🍱 급식 | NEIS API를 통해 오늘/내일/모레 아침·점심·저녁 메뉴 및 칼로리 조회 | | 📅 시간표 | 학년·반별 시간표 자동 조회 (서버 역할 기반 자동 인식) | | 📆 학사일정 | 당월 및 특정 월의 학교 행사·일정 조회 | | 💰 경제 | 출석 보상, 노동, 잔액 확인, 송금, 지원금, 랭킹 등 서버별 독립 경제 시스템 | @@ -65,6 +65,8 @@ SSNYD/ | `!점심` / `!오늘점심` | 오늘 점심 메뉴 | | `!저녁` / `!오늘저녁` | 오늘 저녁 메뉴 | | `!내일아침` / `!내일점심` / `!내일저녁` | 내일 각 끼니 메뉴 | +| `!모레아침` / `!모레점심` / `!모레저녁` | 모레 각 끼니 메뉴 | +| `/급식 끼니 특정날짜:10/5` | 특정 날짜 끼니 메뉴 | ### 📅 시간표 diff --git a/commands.ts b/commands.ts index 757f7f9..65f6a60 100644 --- a/commands.ts +++ b/commands.ts @@ -35,9 +35,20 @@ const commands: RESTPostAPIApplicationCommandsJSONBody[] = [ .addStringOption((opt) => opt .setName("날짜") - .setDescription("오늘 또는 내일 (기본: 오늘)") + .setDescription("오늘, 내일 또는 모레 (기본: 오늘)") .setRequired(false) - .addChoices({ name: "오늘", value: "오늘" }, { name: "내일", value: "내일" }), + .addChoices( + { name: "오늘", value: "오늘" }, + { name: "내일", value: "내일" }, + { name: "모레", value: "모레" }, + ), + ) + .addStringOption((opt) => + opt + .setName("특정날짜") + .setDescription("조회할 날짜 (예: 10/5, 1005, 2026-10-05) — 입력 시 날짜 선택보다 우선") + .setRequired(false) + .setMaxLength(10), ) .toJSON(), diff --git a/index.ts b/index.ts index 56a00a8..f12c633 100644 --- a/index.ts +++ b/index.ts @@ -36,6 +36,8 @@ const HELP_EMBED = new EmbedBuilder() "`!아침` / `!점심` / `!저녁` — 오늘 해당 끼니", "`!오늘아침` / `!오늘점심` / `!오늘저녁`", "`!내일아침` / `!내일점심` / `!내일저녁`", + "`!모레아침` / `!모레점심` / `!모레저녁`", + "`/급식 끼니 특정날짜:10/5` — 특정 날짜 급식", "`!밥 on` / `!밥 off` / `/밥 설정:on|off` — 급식 기능 켜기/끄기 (관리자)", ].join("\n"), }, diff --git a/meal/date.test.ts b/meal/date.test.ts new file mode 100644 index 0000000..6c28df0 --- /dev/null +++ b/meal/date.test.ts @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseMealDate } from "./handler.js"; + +test("연도 생략 형식은 올해로 본다", () => { + assert.equal(parseMealDate("10/5", 2026), "20261005"); + assert.equal(parseMealDate("10-05", 2026), "20261005"); + assert.equal(parseMealDate("10.5", 2026), "20261005"); + assert.equal(parseMealDate("1005", 2026), "20261005"); +}); + +test("연도 포함 형식", () => { + assert.equal(parseMealDate("2027-03-02", 2026), "20270302"); + assert.equal(parseMealDate("20270302", 2026), "20270302"); + assert.equal(parseMealDate(" 2027/3/2 ", 2026), "20270302"); +}); + +test("없는 날짜나 잘못된 형식은 null", () => { + assert.equal(parseMealDate("2/30", 2026), null); + assert.equal(parseMealDate("13/1", 2026), null); + assert.equal(parseMealDate("2026-02-29", 2026), null); + assert.equal(parseMealDate("내일", 2026), null); + assert.equal(parseMealDate("105", 2026), null); +}); + +test("윤년 2월 29일", () => { + assert.equal(parseMealDate("2/29", 2028), "20280229"); +}); diff --git a/meal/handler.ts b/meal/handler.ts index 371620f..e1ddf53 100644 --- a/meal/handler.ts +++ b/meal/handler.ts @@ -11,7 +11,7 @@ import { getMealEnabled, setMealEnabled } from "../db.js"; import { Ctx, ctxFromMessage, ctxFromInteraction, handleAdminToggle } from "../ctx.js"; import { TtlCache } from "../cache.js"; -type MealDay = "auto" | "today" | "tomorrow"; +type MealDay = "auto" | "today" | "tomorrow" | "dayAfter"; // 텍스트 명령 → (끼니, 날짜). "auto"는 지금 시각 기준 다음 끼니. const MEAL_CMDS: Record = { @@ -31,6 +31,9 @@ const MEAL_CMDS: Record = { "!내일아침": { type: 1, day: "tomorrow" }, "!내일점심": { type: 2, day: "tomorrow" }, "!내일저녁": { type: 3, day: "tomorrow" }, + "!모레아침": { type: 1, day: "dayAfter" }, + "!모레점심": { type: 2, day: "dayAfter" }, + "!모레저녁": { type: 3, day: "dayAfter" }, }; const MEAL_LABELS: Record = { 1: "조식", 2: "중식", 3: "석식" }; @@ -62,6 +65,8 @@ function resolveMeal(type: number, day: MealDay): MealTarget { if (day === "today") return { type, dateStr: todayStr, dayLabel: "오늘" }; if (day === "tomorrow") return { type, dateStr: tomorrowStr, dayLabel: "내일" }; + if (day === "dayAfter") + return { type, dateStr: toNeisDateStr(new Date(kst.getTime() + 2 * DAY_MS)), dayLabel: "모레" }; const t = kst.getUTCHours() * 60 + kst.getUTCMinutes(); if (t < 7 * 60 + 40) return { type: 1, dateStr: todayStr, dayLabel: "오늘" }; @@ -70,6 +75,26 @@ function resolveMeal(type: number, day: MealDay): MealTarget { return { type: 1, dateStr: tomorrowStr, dayLabel: "내일" }; } +// "10/5", "10-05", "1005", "2026-10-05", "20261005" 같은 입력을 NEIS 날짜(YYYYMMDD)로 바꾼다. +// 연도를 생략하면 올해로 본다. 형식이 틀리거나 없는 날짜(2/30 등)면 null. +export function parseMealDate(input: string, currentYear: number): string | null { + const s = input.trim().replace(/\s+/g, ""); + const m = + s.match(/^(\d{4})[./-](\d{1,2})[./-](\d{1,2})$/) ?? + s.match(/^(\d{4})(\d{2})(\d{2})$/) ?? + s.match(/^()(\d{1,2})[./-](\d{1,2})$/) ?? + s.match(/^()(\d{2})(\d{2})$/); + if (!m) return null; + const y = m[1] ? Number(m[1]) : currentYear; + const mo = Number(m[2]); + const d = Number(m[3]); + const date = new Date(Date.UTC(y, mo - 1, d)); + if (date.getUTCFullYear() !== y || date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d) { + return null; + } + return toNeisDateStr(date); +} + async function fetchMeal(dateStr: string, mealType: number): Promise { const json = await fetchNeis("mealServiceDietInfo", { pIndex: 1, @@ -217,6 +242,23 @@ export async function handleMealSlash(interaction: ChatInputCommandInteraction): // commandName === "급식" const mealMap: Record = { 아침: 1, 점심: 2, 저녁: 3 }; const type = mealMap[interaction.options.getString("끼니", true)]; - const day = interaction.options.getString("날짜") === "내일" ? "tomorrow" : "today"; + + // 특정날짜가 있으면 날짜 선택보다 우선한다 + const customDate = interaction.options.getString("특정날짜"); + if (customDate) { + const dateStr = parseMealDate(customDate, kstNow().getUTCFullYear()); + if (!dateStr) { + await ctx.replyPrivate( + "❌ 날짜 형식이 올바르지 않습니다. 예: `10/5`, `1005`, `2026-10-05`, `20261005`", + ); + return; + } + const dayLabel = `${parseInt(dateStr.slice(4, 6))}월 ${parseInt(dateStr.slice(6, 8))}일`; + await executeMeal(ctx, { type, dateStr, dayLabel }); + return; + } + + const dayMap: Record = { 오늘: "today", 내일: "tomorrow", 모레: "dayAfter" }; + const day = dayMap[interaction.options.getString("날짜") ?? "오늘"] ?? "today"; await executeMeal(ctx, resolveMeal(type, day)); }