Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
32e1920
chore(deps): bump actions/setup-node
dependabot[bot] Jul 19, 2026
4331355
Merge pull request #59 from CKLOB/dependabot/github_actions/develop/g…
koreahghg Aug 27, 2026
50c6254
feat: 키워드 입력 시 등록된 GIF 자동 전송 기능 추가
cfcromn Sep 22, 2026
ce06100
feat: GIF 등록/목록/삭제 슬래시 명령어 추가 및 다운로드 방어 보강
cfcromn Sep 22, 2026
c9f185b
Merge pull request #66 from CKLOB/feat/gif-trigger
cfcromn Sep 22, 2026
3e9a6a2
feat: 메시지 중간에 키워드가 있어도 GIF가 나가도록 변경
cfcromn Sep 22, 2026
43cdbc7
Merge pull request #67 from CKLOB/feat/gif-substring
cfcromn Sep 22, 2026
9949709
feat: GIF 등록 용량 제한을 12MB로 확대
cfcromn Sep 22, 2026
789edb6
feat: 메시지에 포함된 모든 GIF 키워드 트리거
cfcromn Sep 22, 2026
76167ce
feat: 메시지당 GIF 트리거를 최대 4개로 제한
cfcromn Sep 22, 2026
64bc10f
Merge pull request #68 from CKLOB/feat/gif-multiple-triggers
cfcromn Sep 22, 2026
c32f5a5
perf: 응답속도 개선 — 캐싱 + DB 왕복/ack 지연 정리
koreahghg Sep 22, 2026
3661fd3
Merge pull request #69 from CKLOB/perf/response-speed
koreahghg Sep 22, 2026
d552ba8
feat: 급식 기능 서버별 on/off 토글 추가
koreahghg Sep 22, 2026
b3a5253
Merge pull request #70 from CKLOB/feat/meal-toggle
koreahghg Sep 22, 2026
1410233
fix: 급식 기능 꺼졌을 때 응답 자체를 하지 않도록 변경
koreahghg Sep 22, 2026
6b078a3
Merge pull request #71 from CKLOB/fix/meal-toggle-silent
koreahghg Sep 22, 2026
d52b6d3
feat: 급식 캐시 개선 및 관리자 권한 에러를 본인만 보이게 변경
koreahghg Sep 24, 2026
f3ff837
feat: 전체 성능 개선 및 텍스트·슬래시 명령 로직 리팩토링
koreahghg Sep 24, 2026
5ea0699
Merge pull request #73 from CKLOB/feat/meal-toggle-ephemeral
koreahghg Sep 24, 2026
a268ac4
Merge pull request #72 from CKLOB/feat/performance-refactor
koreahghg Sep 24, 2026
fd85a0b
feat: 급식 모레 조회 및 특정 날짜 조회 추가
koreahghg Sep 29, 2026
f43a6bb
Merge pull request #76 from CKLOB/feat/meal-custom-date
koreahghg Sep 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
- uses: actions/checkout@v7

- name: Use Node.js 22
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: "22"
cache: npm
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

| 카테고리 | 설명 |
| ----------- | ------------------------------------------------------------------------- |
| 🍱 급식 | NEIS API를 통해 오늘/내일 아침·점심·저녁 메뉴 및 칼로리 조회 |
| 🍱 급식 | NEIS API를 통해 오늘/내일/모레 아침·점심·저녁 메뉴 및 칼로리 조회 |
| 📅 시간표 | 학년·반별 시간표 자동 조회 (서버 역할 기반 자동 인식) |
| 📆 학사일정 | 당월 및 특정 월의 학교 행사·일정 조회 |
| 💰 경제 | 출석 보상, 노동, 잔액 확인, 송금, 지원금, 랭킹 등 서버별 독립 경제 시스템 |
Expand Down Expand Up @@ -65,6 +65,8 @@ SSNYD/
| `!점심` / `!오늘점심` | 오늘 점심 메뉴 |
| `!저녁` / `!오늘저녁` | 오늘 저녁 메뉴 |
| `!내일아침` / `!내일점심` / `!내일저녁` | 내일 각 끼니 메뉴 |
| `!모레아침` / `!모레점심` / `!모레저녁` | 모레 각 끼니 메뉴 |
| `/급식 끼니 특정날짜:10/5` | 특정 날짜 끼니 메뉴 |

### 📅 시간표

Expand Down
82 changes: 31 additions & 51 deletions academic/handler.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,50 @@
import https from "https";
import { EmbedBuilder, Message, ChatInputCommandInteraction } from "discord.js";
import {
kstNow,
NEIS_KEY,
ATPT_CODE,
SCHOOL_CODE,
fetchWithRetry,
getFallbackSchedule,
} from "../utils.js";
import { kstNow, fetchWithRetry, fetchNeis, getFallbackSchedule } from "../utils.js";
import { Ctx, ctxFromMessage, ctxFromInteraction } from "../ctx.js";
import { TtlCache } from "../cache.js";

interface ScheduleRow {
AA_YMD: string;
EVENT_NM: string;
}

function fetchAcademicSchedule(year: number, month: number): Promise<ScheduleRow[]> {
// 학사일정은 월 단위 조회라 재요청이 많고, 등록된 일정은 당일 바뀌는 일이 거의 없어 TTL을 길게 둔다.
const CACHE_TTL = 3 * 60 * 60 * 1000;
const academicCache = new TtlCache<ScheduleRow[]>(24);

async function fetchAcademicSchedule(year: number, month: number): Promise<ScheduleRow[]> {
const mm = String(month).padStart(2, "0");
const lastDay = new Date(year, month, 0).getDate();
const fromDate = `${year}${mm}01`;
const toDate = `${year}${mm}${String(lastDay).padStart(2, "0")}`;

const url =
`https://open.neis.go.kr/hub/SchoolSchedule` +
`?KEY=${NEIS_KEY}&Type=json` +
`&ATPT_OFCDC_SC_CODE=${ATPT_CODE}` +
`&SD_SCHUL_CODE=${SCHOOL_CODE}` +
`&AA_FROM_YMD=${fromDate}&AA_TO_YMD=${toDate}`;

return new Promise((resolve, reject) => {
const req = https.get(url, (res) => {
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode}`));
}
res.setEncoding("utf8");
let raw = "";
res.on("data", (chunk: string) => (raw += chunk));
res.on("end", () => {
try {
const json = JSON.parse(raw);
if (json.RESULT) {
if (json.RESULT.CODE === "INFO-200") return resolve([]);
return reject(new Error(`${json.RESULT.CODE}: ${json.RESULT.MESSAGE}`));
}
resolve(json.SchoolSchedule?.[1]?.row ?? []);
} catch (e) {
reject(e);
}
});
});
req.setTimeout(8000, () => req.destroy(new Error("NEIS API timeout")));
req.on("error", reject);
const json = await fetchNeis("SchoolSchedule", {
AA_FROM_YMD: `${year}${mm}01`,
AA_TO_YMD: `${year}${mm}${String(lastDay).padStart(2, "0")}`,
});
if (json.RESULT) {
if (json.RESULT.CODE === "INFO-200") return [];
throw new Error(`${json.RESULT.CODE}: ${json.RESULT.MESSAGE}`);
}
return json.SchoolSchedule?.[1]?.row ?? [];
}

async function executeAcademic(ctx: Ctx, year: number, month: number): Promise<void> {
try {
let rows: ScheduleRow[] = [];
const cacheKey = `${year}-${month}`;
const cached = academicCache.get(cacheKey);
let rows = cached ?? [];
let isFallback = false;
try {
rows = await fetchWithRetry(() => fetchAcademicSchedule(year, month));
} catch (err) {
console.warn(
`[NEIS] 학사일정 API 호출 실패, fallback 데이터 사용을 시도합니다: ${(err as Error).message}`,
);
if (cached === undefined) {
await ctx.defer();
try {
rows = await academicCache.getOrLoad(
cacheKey,
() => fetchWithRetry(() => fetchAcademicSchedule(year, month)),
() => CACHE_TTL,
);
} catch (err) {
console.warn(
`[NEIS] 학사일정 API 호출 실패, fallback 데이터 사용을 시도합니다: ${(err as Error).message}`,
);
}
}
if (rows.length === 0) {
const fbRows = getFallbackSchedule(year, month);
Expand Down
85 changes: 85 additions & 0 deletions cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import test from "node:test";
import assert from "node:assert/strict";
import { TtlCache } from "./cache.js";

test("동시에 들어온 같은 키 요청은 로더를 한 번만 부른다", async () => {
const cache = new TtlCache<number>();
let calls = 0;
const loader = async () => {
calls++;
await new Promise((r) => setTimeout(r, 10));
return 42;
};
const results = await Promise.all([
cache.getOrLoad("k", loader, () => 1000),
cache.getOrLoad("k", loader, () => 1000),
cache.getOrLoad("k", loader, () => 1000),
]);
assert.deepEqual(results, [42, 42, 42]);
assert.equal(calls, 1);
assert.equal(await cache.getOrLoad("k", loader, () => 1000), 42);
assert.equal(calls, 1);
});

test("null도 캐시된다 (결과 없음 캐싱)", async () => {
const cache = new TtlCache<number | null>();
let calls = 0;
const loader = async () => {
calls++;
return null;
};
await cache.getOrLoad("k", loader, () => 1000);
assert.equal(cache.get("k"), null);
await cache.getOrLoad("k", loader, () => 1000);
assert.equal(calls, 1);
});

test("TTL이 지나면 다시 불러온다", async () => {
const cache = new TtlCache<number>();
cache.set("k", 1, 5);
await new Promise((r) => setTimeout(r, 15));
assert.equal(cache.get("k"), undefined);
});

test("로더가 실패하면 캐시하지 않고 다음 요청에서 다시 시도한다", async () => {
const cache = new TtlCache<number>();
await assert.rejects(
cache.getOrLoad(
"k",
() => Promise.reject(new Error("boom")),
() => 1000,
),
);
assert.equal(
await cache.getOrLoad(
"k",
async () => 7,
() => 1000,
),
7,
);
});

test("load는 캐시를 무시하고 새로 불러온다 (예열)", async () => {
const cache = new TtlCache<number>();
cache.set("k", 1, 1000);
assert.equal(
await cache.load(
"k",
async () => 2,
() => 1000,
),
2,
);
assert.equal(cache.get("k"), 2);
});

test("최대 개수를 넘으면 오래된 것부터 버린다", () => {
const cache = new TtlCache<number>(2);
cache.set("a", 1, 1000);
cache.set("b", 2, 1000);
cache.set("c", 3, 1000);
assert.equal(cache.get("a"), undefined);
assert.equal(cache.get("b"), 2);
assert.equal(cache.get("c"), 3);
});
57 changes: 57 additions & 0 deletions cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// 조회 결과를 TTL 동안 보관하고, 같은 키로 동시에 들어온 요청은 진행 중인 호출 하나를 공유한다.
// (점심시간에 여러 명이 동시에 !밥을 쳐도 외부 API는 한 번만 부른다)
export class TtlCache<V> {
private readonly entries = new Map<string, { value: V; expiresAt: number }>();
private readonly inflight = new Map<string, Promise<V>>();

constructor(private readonly maxEntries = 500) {}

// 캐시 미스는 undefined — null은 "결과 없음"을 캐시한 정상 값으로 취급한다
get(key: string): V | undefined {
const entry = this.entries.get(key);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
this.entries.delete(key);
return undefined;
}
return entry.value;
}

set(key: string, value: V, ttlMs: number): void {
if (!this.entries.has(key) && this.entries.size >= this.maxEntries) this.prune();
this.entries.set(key, { value, expiresAt: Date.now() + ttlMs });
}

// 캐시를 무시하고 새로 불러온다 (예열용). 이미 불러오는 중이면 그 결과를 같이 기다린다.
load(key: string, loader: () => Promise<V>, ttlMs: (value: V) => number): Promise<V> {
const running = this.inflight.get(key);
if (running) return running;

const promise = loader()
.then((value) => {
this.set(key, value, ttlMs(value));
return value;
})
.finally(() => this.inflight.delete(key));
this.inflight.set(key, promise);
return promise;
}

getOrLoad(key: string, loader: () => Promise<V>, ttlMs: (value: V) => number): Promise<V> {
const hit = this.get(key);
return hit !== undefined ? Promise.resolve(hit) : this.load(key, loader, ttlMs);
}

private prune(): void {
const now = Date.now();
for (const [key, entry] of this.entries) {
if (entry.expiresAt <= now) this.entries.delete(key);
}
// 만료된 게 없으면 가장 오래 전에 넣은 것부터 버린다 (Map은 삽입 순서를 유지)
while (this.entries.size >= this.maxEntries) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined) break;
this.entries.delete(oldest);
}
}
}
Loading
Loading