From d52b6d390bafcbd6f33ec8e3f48ff57bb9cfdaf0 Mon Sep 17 00:00:00 2001 From: koreahghg Date: Thu, 24 Sep 2026 18:21:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B8=89=EC=8B=9D=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=20=EC=97=90=EB=9F=AC=EB=A5=BC=20=EB=B3=B8?= =?UTF-8?q?=EC=9D=B8=EB=A7=8C=20=EB=B3=B4=EC=9D=B4=EA=B2=8C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commands.ts | 12 +- index.ts | 1 + meal/handler.ts | 398 +++++++++++++++++++----------------------------- 3 files changed, 172 insertions(+), 239 deletions(-) diff --git a/commands.ts b/commands.ts index d2926cf..757f7f9 100644 --- a/commands.ts +++ b/commands.ts @@ -6,7 +6,17 @@ import { } from "discord.js"; const commands: RESTPostAPIApplicationCommandsJSONBody[] = [ - new SlashCommandBuilder().setName("밥").setDescription("현재 시간대 급식을 확인합니다").toJSON(), + new SlashCommandBuilder() + .setName("밥") + .setDescription("현재 시간대 급식을 확인합니다") + .addStringOption((opt) => + opt + .setName("설정") + .setDescription("급식 기능 on/off (관리자 전용)") + .setRequired(false) + .addChoices({ name: "on", value: "on" }, { name: "off", value: "off" }), + ) + .toJSON(), new SlashCommandBuilder() .setName("급식") diff --git a/index.ts b/index.ts index b74fd03..56a00a8 100644 --- a/index.ts +++ b/index.ts @@ -36,6 +36,7 @@ const HELP_EMBED = new EmbedBuilder() "`!아침` / `!점심` / `!저녁` — 오늘 해당 끼니", "`!오늘아침` / `!오늘점심` / `!오늘저녁`", "`!내일아침` / `!내일점심` / `!내일저녁`", + "`!밥 on` / `!밥 off` / `/밥 설정:on|off` — 급식 기능 켜기/끄기 (관리자)", ].join("\n"), }, { diff --git a/meal/handler.ts b/meal/handler.ts index 37f86f9..371620f 100644 --- a/meal/handler.ts +++ b/meal/handler.ts @@ -1,300 +1,222 @@ -import { - EmbedBuilder, - Message, - ChatInputCommandInteraction, - PermissionFlagsBits, -} from "discord.js"; -import https from "https"; +import { EmbedBuilder, Message, ChatInputCommandInteraction } from "discord.js"; import { kstNow, toNeisDateStr, fetchWithRetry, + fetchNeis, + onEveryKstMinute, getFallbackMeal, - NEIS_KEY, - ATPT_CODE, - SCHOOL_CODE, } from "../utils.js"; import { getMealEnabled, setMealEnabled } from "../db.js"; -import { Ctx, ctxFromMessage, ctxFromInteraction } from "../ctx.js"; - -const MEAL_CMDS = new Set([ - "!밥", - "!ㅂ", - "!q", - "!급식", - "!ㄱㅅ", - "!ㄳ", - "!rt", - "!오늘아침", - "!아침", - "!오늘점심", - "!점심", - "!오늘저녁", - "!저녁", - "!내일아침", - "!내일점심", - "!내일저녁", -]); +import { Ctx, ctxFromMessage, ctxFromInteraction, handleAdminToggle } from "../ctx.js"; +import { TtlCache } from "../cache.js"; + +type MealDay = "auto" | "today" | "tomorrow"; + +// 텍스트 명령 → (끼니, 날짜). "auto"는 지금 시각 기준 다음 끼니. +const MEAL_CMDS: Record = { + "!밥": { type: 0, day: "auto" }, + "!ㅂ": { type: 0, day: "auto" }, + "!q": { type: 0, day: "auto" }, + "!급식": { type: 0, day: "auto" }, + "!ㄱㅅ": { type: 0, day: "auto" }, + "!ㄳ": { type: 0, day: "auto" }, + "!rt": { type: 0, day: "auto" }, + "!오늘아침": { type: 1, day: "today" }, + "!아침": { type: 1, day: "today" }, + "!오늘점심": { type: 2, day: "today" }, + "!점심": { type: 2, day: "today" }, + "!오늘저녁": { type: 3, day: "today" }, + "!저녁": { type: 3, day: "today" }, + "!내일아침": { type: 1, day: "tomorrow" }, + "!내일점심": { type: 2, day: "tomorrow" }, + "!내일저녁": { type: 3, day: "tomorrow" }, +}; const MEAL_LABELS: Record = { 1: "조식", 2: "중식", 3: "석식" }; +const DAY_MS = 24 * 60 * 60 * 1000; + +// 예열이 30분마다 돌기 때문에 TTL을 그보다 약간 길게 둬서 예열 사이에 캐시가 비는 구간을 없앤다. +// "급식 없음"(주말/방학)도 짧게 캐시해서 매번 NEIS를 다시 부르지 않게 한다. +const MEAL_TTL_MS = 40 * 60 * 1000; +const NO_MEAL_TTL_MS = 10 * 60 * 1000; +const mealTtl = (r: MealResult | null) => (r ? MEAL_TTL_MS : NO_MEAL_TTL_MS); + +interface MealResult { + menu: string; + cal: string; +} -const CACHE_TTL = 30 * 60 * 1000; -const mealCache = new Map(); +const mealCache = new TtlCache(100); -interface MealTime { +interface MealTarget { type: number; dateStr: string; dayLabel: string; } -function getMealByTime(): MealTime { +function resolveMeal(type: number, day: MealDay): MealTarget { const kst = kstNow(); - const t = kst.getUTCHours() * 60 + kst.getUTCMinutes(); const todayStr = toNeisDateStr(kst); - const tomorrowStr = toNeisDateStr(new Date(kst.getTime() + 24 * 60 * 60 * 1000)); + const tomorrowStr = toNeisDateStr(new Date(kst.getTime() + DAY_MS)); + if (day === "today") return { type, dateStr: todayStr, dayLabel: "오늘" }; + if (day === "tomorrow") return { type, dateStr: tomorrowStr, dayLabel: "내일" }; + + const t = kst.getUTCHours() * 60 + kst.getUTCMinutes(); if (t < 7 * 60 + 40) return { type: 1, dateStr: todayStr, dayLabel: "오늘" }; if (t < 12 * 60 + 40) return { type: 2, dateStr: todayStr, dayLabel: "오늘" }; if (t < 18 * 60 + 40) return { type: 3, dateStr: todayStr, dayLabel: "오늘" }; return { type: 1, dateStr: tomorrowStr, dayLabel: "내일" }; } -interface MealResult { - menu: string; - cal: string; +async function fetchMeal(dateStr: string, mealType: number): Promise { + const json = await fetchNeis("mealServiceDietInfo", { + pIndex: 1, + pSize: 10, + MLSV_YMD: dateStr, + MMEAL_SC_CODE: mealType, + }); + const row = json.mealServiceDietInfo?.[1]?.row?.[0]; + if (!row) return null; + const menu = String(row.DDISH_NM) + .replace(/\*/g, "") + .split(//i) + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => `- ${item}`) + .join("\n"); + return { menu, cal: row.CAL_INFO || "" }; } -function fetchMeal(dateStr: string, mealType: number): Promise { - const url = - `https://open.neis.go.kr/hub/mealServiceDietInfo` + - `?KEY=${NEIS_KEY}&Type=json&pIndex=1&pSize=10` + - `&ATPT_OFCDC_SC_CODE=${ATPT_CODE}` + - `&SD_SCHUL_CODE=${SCHOOL_CODE}` + - `&MLSV_YMD=${dateStr}` + - `&MMEAL_SC_CODE=${mealType}`; - - console.log(`[NEIS] 급식 요청 — ${dateStr} type=${mealType}`); - return new Promise((resolve, reject) => { - const req = https.get(url, (res) => { - console.log(`[NEIS] 급식 응답 — status=${res.statusCode}`); - if (res.statusCode !== 200) { - res.resume(); - reject(new Error(`HTTP ${res.statusCode}`)); - return; - } - res.setEncoding("utf8"); - let raw = ""; - res.on("data", (chunk: string) => (raw += chunk)); - res.on("end", () => { - console.log(`[NEIS] 급식 raw (${raw.length}B) — ${raw.slice(0, 200)}`); - try { - const json = JSON.parse(raw); - if (!json.mealServiceDietInfo) { - resolve(null); - return; - } - const row = json.mealServiceDietInfo[1].row[0]; - const menu = row.DDISH_NM.replace(/\*/g, "") - .split(//i) - .map((item: string) => item.trim()) - .filter((item: string) => item) - .map((item: string) => `- ${item}`) - .join("\n"); - resolve({ menu, cal: row.CAL_INFO || "" }); - } catch (e) { - reject(e); - } - }); - }); - req.setTimeout(4000, () => req.destroy(new Error("NEIS API 요청 시간 초과"))); - req.on("error", reject); - }); +function loadMeal(dateStr: string, mealType: number, force = false): Promise { + const key = `${dateStr}_${mealType}`; + const loader = () => fetchWithRetry(() => fetchMeal(dateStr, mealType)); + return force ? mealCache.load(key, loader, mealTtl) : mealCache.getOrLoad(key, loader, mealTtl); } -async function warmMealCacheForToday(): Promise { - const dateStr = toNeisDateStr(kstNow()); - for (const mealType of [1, 2, 3]) { - try { - const result = await fetchWithRetry(() => fetchMeal(dateStr, mealType)); - if (result) mealCache.set(`${dateStr}_${mealType}`, { data: result, cachedAt: Date.now() }); - } catch (err) { - console.warn(`[NEIS] 급식 캐시 예열 실패 (type=${mealType}): ${(err as Error).message}`); +// 오늘 + 내일 급식을 미리 받아둔다 (저녁 이후 !밥은 내일 아침을 보여주므로 내일 것도 필요) +async function warmMealCache(): Promise { + const kst = kstNow(); + const dates = [toNeisDateStr(kst), toNeisDateStr(new Date(kst.getTime() + DAY_MS))]; + const jobs = dates.flatMap((dateStr) => [1, 2, 3].map((type) => ({ dateStr, type }))); + const results = await Promise.allSettled(jobs.map((j) => loadMeal(j.dateStr, j.type, true))); + results.forEach((r, i) => { + if (r.status === "rejected") { + const { dateStr, type } = jobs[i]; + console.warn(`[NEIS] 급식 캐시 예열 실패 (${dateStr} type=${type}): ${r.reason?.message}`); } - } + }); } export function initMealCacheWarmer(): void { - let lastFiredMinute = -1; - setInterval(() => { - const kst = kstNow(); - const minuteKey = kst.getUTCHours() * 60 + kst.getUTCMinutes(); - if (minuteKey === lastFiredMinute) return; - if (kst.getUTCMinutes() % 30 === 0) { - lastFiredMinute = minuteKey; - warmMealCacheForToday().catch((err) => - console.error("[NEIS] 급식 캐시 예열 실패:", (err as Error).message), - ); - } - }, 30 * 1000); + warmMealCache().catch(() => {}); + onEveryKstMinute((kst) => { + if (kst.getUTCMinutes() % 30 === 0) return warmMealCache(); + }); } -async function executeMeal( - ctx: Ctx, - mealType: number, - dateStr: string, - dayLabel: string, -): Promise { - try { - const cacheKey = `${dateStr}_${mealType}`; - const cached = mealCache.get(cacheKey); +function errorEmbed(error: Error): EmbedBuilder { + return new EmbedBuilder() + .setColor(0xef4444) + .setTitle("❌ 급식 정보 오류") + .addFields( + { name: "오류 유형", value: error.name || "Error", inline: true }, + { name: "재시도", value: "2회 재시도 후 실패", inline: true }, + { name: "메시지", value: error.message || "알 수 없는 오류", inline: false }, + ) + .setTimestamp(); +} - let result: MealResult | null = null; - let isFallback = false; - if (cached && Date.now() - cached.cachedAt < CACHE_TTL) { - result = cached.data; - } else { - try { - result = await fetchWithRetry(() => fetchMeal(dateStr, mealType)); - if (result) mealCache.set(cacheKey, { data: result, cachedAt: Date.now() }); - } catch (err) { - console.warn( - `[NEIS] 급식 API 호출 실패, fallback 데이터 사용을 시도합니다: ${(err as Error).message}`, - ); - } - } - if (!result) { - const fb = getFallbackMeal(dateStr, mealType); - if (fb) { - result = fb; - isFallback = true; - } - } - if (result) { - const month = parseInt(dateStr.slice(4, 6)); - const day = parseInt(dateStr.slice(6, 8)); - const embed = new EmbedBuilder() - .setColor(0x3b82f6) - .setTitle(`🍽️ ${month}월 ${day}일 ${MEAL_LABELS[mealType]}`) - .setDescription(result.menu) - .setFooter({ text: isFallback ? `${result.cal} · ⚠️ 수동 등록된 임시 정보` : result.cal }); - ctx.reply({ embeds: [embed] }); - } else { - ctx.reply(`😢 ${dayLabel} ${MEAL_LABELS[mealType]} 급식 정보가 없습니다.`); +async function executeMeal(ctx: Ctx, { type, dateStr, dayLabel }: MealTarget): Promise { + const key = `${dateStr}_${type}`; + let result = mealCache.get(key); + let apiError: Error | null = null; + + if (result === undefined) { + await ctx.defer(); + try { + result = await loadMeal(dateStr, type); + } catch (err) { + apiError = err as Error; + result = null; + console.warn( + `[NEIS] 급식 API 호출 실패, fallback 데이터 사용을 시도합니다: ${apiError.message}`, + ); } - } catch (err) { - console.error(`[NEIS] 급식 최종 실패 —`, err); - const error = err as Error; - const embed = new EmbedBuilder() - .setColor(0xef4444) - .setTitle("❌ 급식 정보 오류") - .addFields( - { name: "오류 유형", value: error.name || "Error", inline: true }, - { name: "재시도", value: "2회 재시도 후 실패", inline: true }, - { name: "메시지", value: error.message || "알 수 없는 오류", inline: false }, - ) - .setTimestamp(); - ctx.reply({ embeds: [embed] }); } -} -async function handleMealToggle(message: Message, args: string[]): Promise { - if (!message.guild) { - await message.reply("❌ 이 명령어는 서버에서만 사용할 수 있습니다."); - return; + let isFallback = false; + if (!result) { + const fb = getFallbackMeal(dateStr, type); + if (fb) { + result = fb; + isFallback = true; + } } - if (!message.member!.permissions.has(PermissionFlagsBits.Administrator)) { - await message.reply("❌ 서버 관리자 권한이 필요합니다."); + + if (!result) { + // API 자체가 실패했는데 "급식 없음"이라고 하면 헷갈리므로 오류를 그대로 보여준다 + await ctx.reply( + apiError + ? { embeds: [errorEmbed(apiError)] } + : `😢 ${dayLabel} ${MEAL_LABELS[type]} 급식 정보가 없습니다.`, + ); return; } - const enable = args[0]?.toLowerCase() === "on"; - await setMealEnabled(message.guild.id, enable); - await message.reply( - enable ? "✅ 급식 기능이 **활성화**되었습니다." : "🔒 급식 기능이 **비활성화**되었습니다.", - ); + + const month = parseInt(dateStr.slice(4, 6)); + const day = parseInt(dateStr.slice(6, 8)); + const embed = new EmbedBuilder() + .setColor(0x3b82f6) + .setTitle(`🍽️ ${month}월 ${day}일 ${MEAL_LABELS[type]}`) + .setDescription(result.menu) + .setFooter({ text: isFallback ? `${result.cal} · ⚠️ 수동 등록된 임시 정보` : result.cal }); + await ctx.reply({ embeds: [embed] }); } export async function handleMeal(message: Message): Promise { - const content = message.content.trim(); - const parts = content.split(/\s+/); - const cmd = parts[0]; - const args = parts.slice(1); + const [cmd, arg] = message.content.trim().split(/\s+/); + const setting = arg?.toLowerCase(); - if (cmd === "!밥" && ["on", "off"].includes(args[0]?.toLowerCase() ?? "")) { - await handleMealToggle(message, args); + if (cmd === "!밥" && (setting === "on" || setting === "off")) { + await handleAdminToggle(ctxFromMessage(message), setting === "on", "급식", setMealEnabled); return true; } - if (!MEAL_CMDS.has(cmd)) return false; + const spec = MEAL_CMDS[cmd]; + if (!spec) return false; - if (message.guild && !(await getMealEnabled(message.guild.id))) { - return true; - } + // 급식 기능이 꺼진 서버에서는 아예 반응하지 않는다 + if (message.guild && !(await getMealEnabled(message.guild.id))) return true; - const kst = kstNow(); - const todayStr = toNeisDateStr(kst); - const tomorrowStr = toNeisDateStr(new Date(kst.getTime() + 24 * 60 * 60 * 1000)); - - let mealType: number, dayLabel: string, dateStr: string; - - if (["!밥", "!ㅂ", "!q", "!급식", "!ㄱㅅ", "!ㄳ", "!rt"].includes(cmd)) { - ({ type: mealType, dayLabel, dateStr } = getMealByTime()); - } else if (["!오늘아침", "!아침"].includes(cmd)) { - mealType = 1; - dayLabel = "오늘"; - dateStr = todayStr; - } else if (["!오늘점심", "!점심"].includes(cmd)) { - mealType = 2; - dayLabel = "오늘"; - dateStr = todayStr; - } else if (["!오늘저녁", "!저녁"].includes(cmd)) { - mealType = 3; - dayLabel = "오늘"; - dateStr = todayStr; - } else if (cmd === "!내일아침") { - mealType = 1; - dayLabel = "내일"; - dateStr = tomorrowStr; - } else if (cmd === "!내일점심") { - mealType = 2; - dayLabel = "내일"; - dateStr = tomorrowStr; - } else { - mealType = 3; - dayLabel = "내일"; - dateStr = tomorrowStr; - } - - await executeMeal(ctxFromMessage(message), mealType, dateStr, dayLabel); + await executeMeal(ctxFromMessage(message), resolveMeal(spec.type, spec.day)); return true; } export async function handleMealSlash(interaction: ChatInputCommandInteraction): Promise { - if (interaction.guildId && !(await getMealEnabled(interaction.guildId))) { - await interaction.reply({ - content: "🔒 현재 서버에서 급식 기능이 비활성화되어 있습니다.", - ephemeral: true, - }); + const ctx = ctxFromInteraction(interaction); + + // /밥 설정:on|off — 관리자가 아니면 본인에게만 보이는 에러로 응답한다 + const setting = interaction.commandName === "밥" ? interaction.options.getString("설정") : null; + if (setting === "on" || setting === "off") { + await handleAdminToggle(ctx, setting === "on", "급식", setMealEnabled); return; } - const commandName = interaction.commandName; - const kst = kstNow(); - const todayStr = toNeisDateStr(kst); - const tomorrowStr = toNeisDateStr(new Date(kst.getTime() + 24 * 60 * 60 * 1000)); - - let mealType: number, dayLabel: string, dateStr: string; + if (interaction.guildId && !(await getMealEnabled(interaction.guildId))) { + await ctx.replyPrivate("🔒 현재 서버에서 급식 기능이 비활성화되어 있습니다."); + return; + } - if (commandName === "밥") { - ({ type: mealType, dayLabel, dateStr } = getMealByTime()); - } else { - // commandName === "급식" - const 끼니 = interaction.options.getString("끼니", true); - const 날짜 = interaction.options.getString("날짜") ?? "오늘"; - const mealMap: Record = { 아침: 1, 점심: 2, 저녁: 3 }; - mealType = mealMap[끼니]; - dayLabel = 날짜; - dateStr = 날짜 === "내일" ? tomorrowStr : todayStr; + if (interaction.commandName === "밥") { + await executeMeal(ctx, resolveMeal(0, "auto")); + return; } - await executeMeal(ctxFromInteraction(interaction), mealType, dateStr, dayLabel); + // commandName === "급식" + const mealMap: Record = { 아침: 1, 점심: 2, 저녁: 3 }; + const type = mealMap[interaction.options.getString("끼니", true)]; + const day = interaction.options.getString("날짜") === "내일" ? "tomorrow" : "today"; + await executeMeal(ctx, resolveMeal(type, day)); }