diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58ab417..5304d4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 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/academic/handler.ts b/academic/handler.ts index eec8688..0156c68 100644 --- a/academic/handler.ts +++ b/academic/handler.ts @@ -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 { +// 학사일정은 월 단위 조회라 재요청이 많고, 등록된 일정은 당일 바뀌는 일이 거의 없어 TTL을 길게 둔다. +const CACHE_TTL = 3 * 60 * 60 * 1000; +const academicCache = new TtlCache(24); + +async function fetchAcademicSchedule(year: number, month: number): Promise { 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 { 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); diff --git a/cache.test.ts b/cache.test.ts new file mode 100644 index 0000000..c18917b --- /dev/null +++ b/cache.test.ts @@ -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(); + 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(); + 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(); + cache.set("k", 1, 5); + await new Promise((r) => setTimeout(r, 15)); + assert.equal(cache.get("k"), undefined); +}); + +test("로더가 실패하면 캐시하지 않고 다음 요청에서 다시 시도한다", async () => { + const cache = new TtlCache(); + 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(); + 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(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); +}); diff --git a/cache.ts b/cache.ts new file mode 100644 index 0000000..83738bf --- /dev/null +++ b/cache.ts @@ -0,0 +1,57 @@ +// 조회 결과를 TTL 동안 보관하고, 같은 키로 동시에 들어온 요청은 진행 중인 호출 하나를 공유한다. +// (점심시간에 여러 명이 동시에 !밥을 쳐도 외부 API는 한 번만 부른다) +export class TtlCache { + private readonly entries = new Map(); + private readonly inflight = new Map>(); + + 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, ttlMs: (value: V) => number): Promise { + 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, ttlMs: (value: V) => number): Promise { + 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); + } + } +} diff --git a/casino/economy.ts b/casino/economy.ts index 3f5de82..20c5748 100644 --- a/casino/economy.ts +++ b/casino/economy.ts @@ -1,5 +1,5 @@ import { EmbedBuilder } from "discord.js"; -import { getUser, updateBalance, setField, getTopUsers } from "../db.js"; +import { getUser, claimReward, creditUser, tryAdjustBalance, getTopUsers } from "../db.js"; import { toMysqlDatetime, toKSTDateStr } from "../utils.js"; import { parseAmountInput } from "./games/shared.js"; import { Ctx } from "../ctx.js"; @@ -15,54 +15,84 @@ function cooldownLeft(lastTime: string | null, ms: number): string | null { return `${h}시간 ${m}분 ${s}초`; } +const ATTENDANCE_REWARD = 150000; +const SUPPORT_REWARD = 100000; +const WORK_COOLDOWN_MS = 60 * 1000; +const SUPPORT_COOLDOWN_MS = 60 * 60 * 1000; +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +// 오늘 KST 자정 시각 (last_* 컬럼은 UTC로 저장되므로 UTC Date로 돌려준다) +function kstMidnight(): Date { + return new Date(new Date(`${toKSTDateStr(new Date())}T00:00:00Z`).getTime() - KST_OFFSET_MS); +} + +function rewardEmbed(color: number, title: string, label: string, reward: number, balance: number) { + return new EmbedBuilder() + .setColor(color) + .setTitle(title) + .addFields( + { name: label, value: `+${reward.toLocaleString()}원`, inline: true }, + { name: "현재 잔액", value: `${balance.toLocaleString()}원`, inline: true }, + ); +} + export async function handleAttendance(ctx: Ctx): Promise { const guildId = ctx.guildId!; const user = await getUser(guildId, ctx.authorId, ctx.username); + const alreadyMsg = "⏳ 오늘 이미 출석했습니다. 내일 다시 출석하세요."; if (user.last_attendance) { const lastDate = toKSTDateStr(new Date(user.last_attendance)); - const today = toKSTDateStr(new Date()); - if (lastDate >= today) { - ctx.reply("⏳ 오늘 이미 출석했습니다. 내일 다시 출석하세요."); + if (lastDate >= toKSTDateStr(new Date())) { + ctx.reply(alreadyMsg); return; } } - await updateBalance(guildId, ctx.authorId, 150000); - await setField(guildId, ctx.authorId, "last_attendance", toMysqlDatetime(new Date())); - const updated = await getUser(guildId, ctx.authorId, ctx.username); - - const embed = new EmbedBuilder() - .setColor(0x22c55e) - .setTitle("📅 출석 완료!") - .addFields( - { name: "보상", value: "+150,000원", inline: true }, - { name: "현재 잔액", value: `${updated.balance.toLocaleString()}원`, inline: true }, - ); - ctx.reply({ embeds: [embed] }); + // 위 검사는 빠른 안내용이고, 실제 지급 여부는 DB 조건부 UPDATE가 결정한다 (연타 중복 지급 방지) + const now = toMysqlDatetime(new Date()); + // DATETIME은 초 단위라 "자정 1초 전 이하" = "오늘 출석 안 함" + const cutoff = toMysqlDatetime(new Date(kstMidnight().getTime() - 1000)); + const balance = await claimReward( + guildId, + ctx.authorId, + "last_attendance", + ATTENDANCE_REWARD, + now, + cutoff, + ); + if (balance === null) { + ctx.reply(alreadyMsg); + return; + } + ctx.reply({ + embeds: [rewardEmbed(0x22c55e, "📅 출석 완료!", "보상", ATTENDANCE_REWARD, balance)], + }); } export async function handleWork(ctx: Ctx): Promise { const guildId = ctx.guildId!; const user = await getUser(guildId, ctx.authorId, ctx.username); - const left = cooldownLeft(user.last_work, 60 * 1000); + const left = cooldownLeft(user.last_work, WORK_COOLDOWN_MS); if (left) { ctx.reply(`⏳ **${left}** 후에 다시 일할 수 있습니다.`); return; } const reward = Math.floor(Math.random() * 20001) + 10000; - await updateBalance(guildId, ctx.authorId, reward); - await setField(guildId, ctx.authorId, "last_work", toMysqlDatetime(new Date())); - const updated = await getUser(guildId, ctx.authorId, ctx.username); - - const embed = new EmbedBuilder() - .setColor(0x3b82f6) - .setTitle("💼 노동 완료!") - .addFields( - { name: "보상", value: `+${reward.toLocaleString()}원`, inline: true }, - { name: "현재 잔액", value: `${updated.balance.toLocaleString()}원`, inline: true }, - ); - ctx.reply({ embeds: [embed] }); + const nowMs = Date.now(); + const balance = await claimReward( + guildId, + ctx.authorId, + "last_work", + reward, + toMysqlDatetime(new Date(nowMs)), + toMysqlDatetime(new Date(nowMs - WORK_COOLDOWN_MS)), + ); + if (balance === null) { + ctx.reply("⏳ 잠시 후에 다시 일할 수 있습니다."); + return; + } + ctx.reply({ embeds: [rewardEmbed(0x3b82f6, "💼 노동 완료!", "보상", reward, balance)] }); } export async function handleBalance(ctx: Ctx): Promise { @@ -77,29 +107,35 @@ export async function handleBalance(ctx: Ctx): Promise { export async function handleSupport(ctx: Ctx): Promise { const guildId = ctx.guildId!; const user = await getUser(guildId, ctx.authorId, ctx.username); + const notZeroMsg = "❌ 잔액이 0원일 때만 지원금을 받을 수 있습니다."; if (user.balance > 0) { - ctx.reply("❌ 잔액이 0원일 때만 지원금을 받을 수 있습니다."); + ctx.reply(notZeroMsg); return; } - const left = cooldownLeft(user.last_support, 60 * 60 * 1000); + const left = cooldownLeft(user.last_support, SUPPORT_COOLDOWN_MS); if (left) { ctx.reply(`⏳ **${left}** 후에 다시 신청할 수 있습니다.`); return; } - await updateBalance(guildId, ctx.authorId, 100000); - await setField(guildId, ctx.authorId, "last_support", toMysqlDatetime(new Date())); - const updated = await getUser(guildId, ctx.authorId, ctx.username); - - const embed = new EmbedBuilder() - .setColor(0xf59e0b) - .setTitle("🆘 지원금 지급") - .addFields( - { name: "지원금", value: "+100,000원", inline: true }, - { name: "현재 잔액", value: `${updated.balance.toLocaleString()}원`, inline: true }, - ); - ctx.reply({ embeds: [embed] }); + const nowMs = Date.now(); + const balance = await claimReward( + guildId, + ctx.authorId, + "last_support", + SUPPORT_REWARD, + toMysqlDatetime(new Date(nowMs)), + toMysqlDatetime(new Date(nowMs - SUPPORT_COOLDOWN_MS)), + { requireZeroBalance: true }, + ); + if (balance === null) { + ctx.reply(notZeroMsg); + return; + } + ctx.reply({ + embeds: [rewardEmbed(0xf59e0b, "🆘 지원금 지급", "지원금", SUPPORT_REWARD, balance)], + }); } export async function handleTransfer( @@ -143,10 +179,13 @@ export async function handleTransfer( const tax = Math.floor(amount * (taxRate / 100)); const received = amount - tax; - await updateBalance(guildId, ctx.authorId, -amount); - await getUser(guildId, targetId, targetUsername); - await updateBalance(guildId, targetId, received); - const senderUpdated = await getUser(guildId, ctx.authorId, ctx.username); + // 잔액 확인과 출금을 한 쿼리로 — 송금을 연달아 보내도 잔액 이상 빠져나가지 않는다 + const senderBalance = await tryAdjustBalance(guildId, ctx.authorId, -amount, amount); + if (senderBalance === null) { + ctx.reply("❌ 잔액이 부족합니다."); + return; + } + await creditUser(guildId, targetId, targetUsername, received); const embed = new EmbedBuilder() .setColor(0x8b5cf6) @@ -157,7 +196,7 @@ export async function handleTransfer( { name: "증여세율", value: `${taxRate}%`, inline: true }, { name: "세금", value: `-${tax.toLocaleString()}원`, inline: true }, { name: "실수령액", value: `${received.toLocaleString()}원`, inline: true }, - { name: "내 잔액", value: `${senderUpdated.balance.toLocaleString()}원`, inline: true }, + { name: "내 잔액", value: `${senderBalance.toLocaleString()}원`, inline: true }, ); ctx.reply({ embeds: [embed] }); } diff --git a/casino/games/baccarat.ts b/casino/games/baccarat.ts index 413fc0e..99a4d4c 100644 --- a/casino/games/baccarat.ts +++ b/casino/games/baccarat.ts @@ -5,8 +5,18 @@ import { ButtonStyle, ButtonInteraction, } from "discord.js"; -import { getUser, updateBalance } from "../../db.js"; -import { sleep, parseBet, fmt, activeGamblers, createDeck, Card } from "./shared.js"; +import { getUser, tryAdjustBalance } from "../../db.js"; +import { + sleep, + parseBet, + fmt, + startGame, + claimButton, + endGame, + insufficientEmbed, + createDeck, + Card, +} from "./shared.js"; import { Ctx } from "../../ctx.js"; type BacSide = "player" | "banker" | "tie"; @@ -71,22 +81,22 @@ export async function handleBaccarat(ctx: Ctx, args: string[]): Promise { } const uid = ctx.authorId; + const gid = startGame(uid); const row = new ActionRowBuilder().addComponents( new ButtonBuilder() - .setCustomId(`bac_player_${uid}_${amount}`) + .setCustomId(`bac_player_${uid}_${amount}_${gid}`) .setLabel("플레이어") .setStyle(ButtonStyle.Secondary), new ButtonBuilder() - .setCustomId(`bac_tie_${uid}_${amount}`) + .setCustomId(`bac_tie_${uid}_${amount}_${gid}`) .setLabel("타이") .setStyle(ButtonStyle.Secondary), new ButtonBuilder() - .setCustomId(`bac_banker_${uid}_${amount}`) + .setCustomId(`bac_banker_${uid}_${amount}_${gid}`) .setLabel("뱅커") .setStyle(ButtonStyle.Secondary), ); - activeGamblers.add(uid); ctx.reply({ embeds: [ new EmbedBuilder() @@ -99,32 +109,26 @@ export async function handleBaccarat(ctx: Ctx, args: string[]): Promise { } export async function handleBaccaratButton(interaction: ButtonInteraction): Promise { - const parts = interaction.customId.split("_"); - const side = parts[1] as BacSide; - const userId = parts[2]; - const amount = parseInt(parts[3]); - - if (interaction.user.id !== userId) { - interaction.reply({ content: "❌ 이 게임은 당신의 게임이 아닙니다.", ephemeral: true }); - return; + const [, sideStr, userId, amountStr, gameId] = interaction.customId.split("_"); + const side = sideStr as BacSide; + const amount = parseInt(amountStr); + if (!(await claimButton(interaction, userId, gameId))) return; + + try { + await playBaccarat(interaction, side, userId, amount); + } finally { + endGame(userId); } +} - const user = await getUser(interaction.guildId!, userId, interaction.user.username); - if (user.balance < amount) { - activeGamblers.delete(userId); - interaction.update({ - embeds: [ - new EmbedBuilder() - .setColor(0xef4444) - .setTitle("🎴 바카라") - .setDescription("❌ 잔액이 부족합니다."), - ], - components: [], - }); - return; - } +async function playBaccarat( + interaction: ButtonInteraction, + side: BacSide, + userId: string, + amount: number, +): Promise { + await interaction.deferUpdate(); - await updateBalance(interaction.guildId!, userId, -amount); const { player, banker, pVal, bVal, winner } = runBaccarat(); const isTie = winner === "tie"; @@ -135,21 +139,19 @@ export async function handleBaccaratButton(interaction: ButtonInteraction): Prom tie: "🤝 타이", }; + // 타이 적중 8배, 뱅커 승리 0.95배, 타이인데 다른 쪽 베팅이면 반환 let delta: number; - if (isTie && side === "tie") { - delta = amount * 8; - await updateBalance(interaction.guildId!, userId, amount + delta); - } else if (isTie) { - delta = 0; - await updateBalance(interaction.guildId!, userId, amount); - } else if (userWin) { - delta = side === "banker" ? Math.floor(amount * 0.95) : amount; - await updateBalance(interaction.guildId!, userId, amount + delta); - } else { - delta = -amount; + if (isTie) delta = side === "tie" ? amount * 8 : 0; + else if (userWin) delta = side === "banker" ? Math.floor(amount * 0.95) : amount; + else delta = -amount; + + // 결과를 먼저 뽑고 손익을 한 번에 반영한다 (예전: 잔액 조회 → 차감 → 지급으로 DB 왕복 최대 6번) + const balance = await tryAdjustBalance(interaction.guildId!, userId, delta, amount); + if (balance === null) { + await interaction.editReply({ embeds: [insufficientEmbed("🎴 바카라")], components: [] }); + return; } - const updated = await getUser(interaction.guildId!, userId, interaction.user.username); const resultText = isTie ? side === "tie" ? "🎉 타이 적중!" @@ -159,8 +161,6 @@ export async function handleBaccaratButton(interaction: ButtonInteraction): Prom : "😔 패배"; const resultColor = userWin || (isTie && side === "tie") ? 0x22c55e : isTie ? 0x6b7280 : 0xef4444; - await interaction.deferUpdate(); - await interaction.editReply({ embeds: [ new EmbedBuilder() @@ -237,9 +237,8 @@ export async function handleBaccaratButton(interaction: ButtonInteraction): Prom { name: "내 베팅", value: sideLabel[side], inline: true }, { name: "판정", value: resultText, inline: true }, { name: "손익", value: fmt(delta), inline: true }, - { name: "현재 잔액", value: `${updated.balance.toLocaleString()}원`, inline: true }, + { name: "현재 잔액", value: `${balance.toLocaleString()}원`, inline: true }, ), ], }); - activeGamblers.delete(userId); } diff --git a/casino/games/blackjack.ts b/casino/games/blackjack.ts index 736aade..24cdd5d 100644 --- a/casino/games/blackjack.ts +++ b/casino/games/blackjack.ts @@ -4,12 +4,20 @@ import { ButtonBuilder, ButtonStyle, ButtonInteraction, + MessageFlags, } from "discord.js"; -import { getUser, updateBalance } from "../../db.js"; -import { parseBet, fmt, activeGamblers, createDeck, Card } from "./shared.js"; +import { + getUser, + getBalance, + updateBalance, + tryAdjustBalance, + updateBalanceAndGet, +} from "../../db.js"; +import { parseBet, fmt, startGame, endGame, createDeck, Card } from "./shared.js"; import { Ctx } from "../../ctx.js"; interface BjGame { + id: string; deck: Card[]; player: Card[]; dealer: Card[]; @@ -18,15 +26,16 @@ interface BjGame { createdAt: number; } +const BJ_TTL_MS = 15 * 60 * 1000; const bjGames = new Map(); setInterval( () => { const now = Date.now(); for (const [userId, game] of bjGames) { - if (now - game.createdAt > 15 * 60 * 1000) { + if (now - game.createdAt > BJ_TTL_MS) { bjGames.delete(userId); - activeGamblers.delete(userId); + endGame(userId); updateBalance(game.guildId, userId, game.bet).catch((e: Error) => { console.error(`[BJ TTL] 환불 실패 (user ${userId}):`, e.message); }); @@ -53,14 +62,14 @@ function bjHandStr(hand: Card[], hideSecond = false): string { return hand.map((c, i) => (hideSecond && i === 1 ? "🂠" : `${c.s}${c.v}`)).join(" "); } -function buildBjRow(userId: string): ActionRowBuilder { +function buildBjRow(userId: string, gameId: string): ActionRowBuilder { return new ActionRowBuilder().addComponents( new ButtonBuilder() - .setCustomId(`bj_hit_${userId}`) + .setCustomId(`bj_hit_${userId}_${gameId}`) .setLabel("히트") .setStyle(ButtonStyle.Secondary), new ButtonBuilder() - .setCustomId(`bj_stand_${userId}`) + .setCustomId(`bj_stand_${userId}_${gameId}`) .setLabel("스탠드") .setStyle(ButtonStyle.Secondary), ); @@ -79,7 +88,11 @@ export async function handleBlackjack(ctx: Ctx, args: string[]): Promise { return; } - await updateBalance(ctx.guildId!, ctx.authorId, -amount!); + // 잔액 확인과 차감을 한 쿼리로 (그 사이 송금으로 잔액이 줄어도 음수가 되지 않게) + if ((await tryAdjustBalance(ctx.guildId!, ctx.authorId, -amount!, amount!)) === null) { + ctx.reply("❌ 잔액이 부족합니다"); + return; + } const deck = createDeck(); const player = [deck.pop()!, deck.pop()!]; @@ -88,17 +101,16 @@ export async function handleBlackjack(ctx: Ctx, args: string[]): Promise { const dVal = bjHandVal(dealer); if (pVal === 21) { - let delta: number, resultText: string; + let delta: number, resultText: string, balance: number; if (dVal === 21) { - await updateBalance(ctx.guildId!, ctx.authorId, amount!); + balance = await updateBalanceAndGet(ctx.guildId!, ctx.authorId, amount!); delta = 0; resultText = "🤝 무승부 (블랙잭 vs 블랙잭)"; } else { - await updateBalance(ctx.guildId!, ctx.authorId, amount! * 2); + balance = await updateBalanceAndGet(ctx.guildId!, ctx.authorId, amount! * 2); delta = amount!; resultText = "🎉 블랙잭! 승리!"; } - const updated = await getUser(ctx.guildId!, ctx.authorId, ctx.username); ctx.reply({ embeds: [ new EmbedBuilder() @@ -109,14 +121,17 @@ export async function handleBlackjack(ctx: Ctx, args: string[]): Promise { { name: "딜러 패", value: `${bjHandStr(dealer)} (${dVal})`, inline: false }, { name: "결과", value: resultText, inline: true }, { name: "손익", value: fmt(delta), inline: true }, - { name: "현재 잔액", value: `${updated.balance.toLocaleString()}원`, inline: true }, + { name: "현재 잔액", value: `${balance.toLocaleString()}원`, inline: true }, ), ], }); return; } + // 방치된 게임은 아래 TTL 정리에서 15분 뒤 환불되므로 도박 잠금도 그보다 조금 길게 잡는다 + const gameId = startGame(ctx.authorId, BJ_TTL_MS + 5 * 60 * 1000); bjGames.set(ctx.authorId, { + id: gameId, deck, player, dealer, @@ -124,7 +139,6 @@ export async function handleBlackjack(ctx: Ctx, args: string[]): Promise { guildId: ctx.guildId!, createdAt: Date.now(), }); - activeGamblers.add(ctx.authorId); ctx.reply({ embeds: [ @@ -137,23 +151,27 @@ export async function handleBlackjack(ctx: Ctx, args: string[]): Promise { ) .setFooter({ text: "버튼을 눌러 진행하세요." }), ], - components: [buildBjRow(ctx.authorId)], + components: [buildBjRow(ctx.authorId, gameId)], }); } export async function handleBjButton(interaction: ButtonInteraction): Promise { - const parts = interaction.customId.split("_"); - const action = parts[1]; - const userId = parts[2]; + const [, action, userId, gameId] = interaction.customId.split("_"); if (interaction.user.id !== userId) { - interaction.reply({ content: "❌ 이 게임은 당신의 게임이 아닙니다.", ephemeral: true }); + interaction.reply({ + content: "❌ 이 게임은 당신의 게임이 아닙니다.", + flags: MessageFlags.Ephemeral, + }); return; } + await interaction.deferUpdate(); + + // 예전 게임 메시지의 버튼으로 지금 게임을 조작하지 못하게 게임 id까지 맞춰 본다 const game = bjGames.get(userId); - if (!game) { - interaction.update({ components: [] }); + if (!game || game.id !== gameId) { + await interaction.editReply({ components: [] }); return; } @@ -163,9 +181,9 @@ export async function handleBjButton(interaction: ButtonInteraction): Promise 21) { bjGames.delete(userId); - activeGamblers.delete(userId); - const updated = await getUser(game.guildId, userId, interaction.user.username); - interaction.update({ + endGame(userId); + const balance = await getBalance(game.guildId, userId); + await interaction.editReply({ embeds: [ new EmbedBuilder() .setColor(0xef4444) @@ -179,7 +197,7 @@ export async function handleBjButton(interaction: ButtonInteraction): Promise 21 || pVal > dVal) { delta = game.bet; resultText = "🎉 승리!"; - await updateBalance(game.guildId, userId, game.bet * 2); + balance = await updateBalanceAndGet(game.guildId, userId, game.bet * 2); } else if (pVal === dVal) { delta = 0; resultText = "🤝 무승부"; - await updateBalance(game.guildId, userId, game.bet); + balance = await updateBalanceAndGet(game.guildId, userId, game.bet); } else { delta = -game.bet; resultText = "😔 패배"; + balance = await getBalance(game.guildId, userId); } - const updated = await getUser(game.guildId, userId, interaction.user.username); - interaction.update({ + await interaction.editReply({ embeds: [ new EmbedBuilder() .setColor(delta > 0 ? 0x22c55e : delta === 0 ? 0x6b7280 : 0xef4444) @@ -236,7 +254,7 @@ export async function handleBjButton(interaction: ButtonInteraction): Promise { } const uid = ctx.authorId; + const gid = startGame(uid); const row = new ActionRowBuilder().addComponents( new ButtonBuilder() - .setCustomId(`cf_heads_${uid}_${amount}`) + .setCustomId(`cf_heads_${uid}_${amount}_${gid}`) .setLabel("앞면") .setStyle(ButtonStyle.Secondary), new ButtonBuilder() - .setCustomId(`cf_tails_${uid}_${amount}`) + .setCustomId(`cf_tails_${uid}_${amount}_${gid}`) .setLabel("뒷면") .setStyle(ButtonStyle.Secondary), ); @@ -39,56 +48,57 @@ export async function handleCoinflip(ctx: Ctx, args: string[]): Promise { .setTitle("🪙 코인플립") .setDescription(`베팅 금액: **${amount!.toLocaleString()}원**\n앞면 / 뒷면 중 선택하세요.`); - activeGamblers.add(uid); ctx.reply({ embeds: [embed], components: [row] }); } export async function handleCoinflipButton(interaction: ButtonInteraction): Promise { - const parts = interaction.customId.split("_"); - const choice = parts[1]; - const userId = parts[2]; - const amount = parseInt(parts[3]); + const [, choice, userId, amountStr, gameId] = interaction.customId.split("_"); + const amount = parseInt(amountStr); + if (!(await claimButton(interaction, userId, gameId))) return; - if (interaction.user.id !== userId) { - interaction.reply({ content: "❌ 이 게임은 당신의 게임이 아닙니다.", ephemeral: true }); - return; - } + try { + await interaction.deferUpdate(); - const result = Math.random() < 0.5 ? "heads" : "tails"; - const win = choice === result; - const delta = win ? amount : -amount; + const result = Math.random() < 0.5 ? "heads" : "tails"; + const win = choice === result; + const delta = win ? amount : -amount; - await updateBalance(interaction.guildId!, userId, delta); - const updated = await getUser(interaction.guildId!, userId, interaction.user.username); - const gifUrl = result === "heads" ? CF_HEADS_GIF : CF_TAILS_GIF; + // 베팅 후 송금 등으로 잔액이 줄었을 수 있어서 정산 시점에 다시 확인한다 (예전엔 잔액이 음수가 될 수 있었다) + const balance = await tryAdjustBalance(interaction.guildId!, userId, delta, amount); + if (balance === null) { + await interaction.editReply({ embeds: [insufficientEmbed("🪙 코인플립")], components: [] }); + return; + } + const gifUrl = result === "heads" ? CF_HEADS_GIF : CF_TAILS_GIF; - await interaction.deferUpdate(); - await interaction.editReply({ - embeds: [ - new EmbedBuilder() - .setColor(0x3b82f6) - .setTitle("🪙 코인플립") - .setDescription("코인이 돌아가고 있습니다...") - .setImage(gifUrl), - ], - components: [], - }); - await sleep(2000); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x3b82f6) + .setTitle("🪙 코인플립") + .setDescription("코인이 돌아가고 있습니다...") + .setImage(gifUrl), + ], + components: [], + }); + await sleep(2000); - await interaction.editReply({ - embeds: [ - new EmbedBuilder() - .setColor(win ? 0x22c55e : 0xef4444) - .setTitle("🪙 코인플립") - .addFields( - { name: "선택", value: choice === "heads" ? "앞면 🪙" : "뒷면 💀", inline: true }, - { name: "결과", value: result === "heads" ? "앞면 🪙" : "뒷면 💀", inline: true }, - { name: "판정", value: win ? "🎉 승리!" : "😔 패배", inline: true }, - { name: "베팅", value: `${amount.toLocaleString()}원`, inline: true }, - { name: "손익", value: fmt(delta), inline: true }, - { name: "현재 잔액", value: `${updated.balance.toLocaleString()}원`, inline: true }, - ), - ], - }); - activeGamblers.delete(userId); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(win ? 0x22c55e : 0xef4444) + .setTitle("🪙 코인플립") + .addFields( + { name: "선택", value: choice === "heads" ? "앞면 🪙" : "뒷면 💀", inline: true }, + { name: "결과", value: result === "heads" ? "앞면 🪙" : "뒷면 💀", inline: true }, + { name: "판정", value: win ? "🎉 승리!" : "😔 패배", inline: true }, + { name: "베팅", value: `${amount.toLocaleString()}원`, inline: true }, + { name: "손익", value: fmt(delta), inline: true }, + { name: "현재 잔액", value: `${balance.toLocaleString()}원`, inline: true }, + ), + ], + }); + } finally { + endGame(userId); + } } diff --git a/casino/games/roulette.ts b/casino/games/roulette.ts index bbca8c7..6e00022 100644 --- a/casino/games/roulette.ts +++ b/casino/games/roulette.ts @@ -5,8 +5,16 @@ import { ButtonStyle, ButtonInteraction, } from "discord.js"; -import { getUser, updateBalance } from "../../db.js"; -import { sleep, parseBet, fmt, activeGamblers } from "./shared.js"; +import { getUser, tryAdjustBalance } from "../../db.js"; +import { + sleep, + parseBet, + fmt, + startGame, + claimButton, + endGame, + insufficientEmbed, +} from "./shared.js"; import { Ctx } from "../../ctx.js"; const RED_NUMS = new Set([1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]); @@ -51,6 +59,17 @@ const ROULETTE_GIFS = [ "https://cdn.discordapp.com/attachments/1481972735684120607/1481987493384687726/0313_133.gif?ex=69b54fe8&is=69b3fe68&hm=efe6cd1d76afe749e1893352c6cf3a441cb5c0ce7d4764538876ec8cf254e4c9&", ]; +const BETS: Record< + string, + { label: string; button: string; payout: number; wins: (n: number) => boolean } +> = { + odd: { label: "홀", button: "홀", payout: 1, wins: (n) => n !== 0 && n % 2 === 1 }, + even: { label: "짝", button: "짝", payout: 1, wins: (n) => n !== 0 && n % 2 === 0 }, + black: { label: "⚫ 검", button: "검", payout: 1, wins: (n) => n !== 0 && !RED_NUMS.has(n) }, + red: { label: "🔴 빨", button: "빨", payout: 1, wins: (n) => RED_NUMS.has(n) }, + zero: { label: "🟢 0", button: "0", payout: 35, wins: (n) => n === 0 }, +}; + export async function handleRoulette(ctx: Ctx, args: string[]): Promise { const user = await getUser(ctx.guildId!, ctx.authorId, ctx.username); const { error, amount } = parseBet(args[0], user.balance); @@ -60,30 +79,16 @@ export async function handleRoulette(ctx: Ctx, args: string[]): Promise { } const uid = ctx.authorId; + const gid = startGame(uid); const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`rl_odd_${uid}_${amount}`) - .setLabel("홀") - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(`rl_even_${uid}_${amount}`) - .setLabel("짝") - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(`rl_black_${uid}_${amount}`) - .setLabel("검") - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(`rl_red_${uid}_${amount}`) - .setLabel("빨") - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(`rl_zero_${uid}_${amount}`) - .setLabel("0") - .setStyle(ButtonStyle.Success), + Object.entries(BETS).map(([type, bet]) => + new ButtonBuilder() + .setCustomId(`rl_${type}_${uid}_${amount}_${gid}`) + .setLabel(bet.button) + .setStyle(type === "zero" ? ButtonStyle.Success : ButtonStyle.Secondary), + ), ); - activeGamblers.add(uid); ctx.reply({ embeds: [ new EmbedBuilder() @@ -98,95 +103,53 @@ export async function handleRoulette(ctx: Ctx, args: string[]): Promise { } export async function handleRouletteButton(interaction: ButtonInteraction): Promise { - const parts = interaction.customId.split("_"); - const betType = parts[1]; - const userId = parts[2]; - const amount = parseInt(parts[3]); + const [, betType, userId, amountStr, gameId] = interaction.customId.split("_"); + const amount = parseInt(amountStr); + const bet = BETS[betType]; + if (!bet || !(await claimButton(interaction, userId, gameId))) return; - if (interaction.user.id !== userId) { - interaction.reply({ content: "❌ 이 게임은 당신의 게임이 아닙니다.", ephemeral: true }); - return; - } + try { + await interaction.deferUpdate(); - const user = await getUser(interaction.guildId!, userId, interaction.user.username); - if (user.balance < amount) { - activeGamblers.delete(userId); - interaction.update({ + const result = Math.floor(Math.random() * 37); + const colorEmoji = result === 0 ? "🟢" : RED_NUMS.has(result) ? "🔴" : "⚫"; + const win = bet.wins(result); + const delta = win ? amount * bet.payout : -amount; + + // 결과를 먼저 뽑고 손익을 한 번에 반영한다 (예전: 잔액 조회 → 차감 → 당첨금 지급으로 DB 왕복 5번) + const balance = await tryAdjustBalance(interaction.guildId!, userId, delta, amount); + if (balance === null) { + await interaction.editReply({ embeds: [insufficientEmbed("🎡 룰렛")], components: [] }); + return; + } + + await interaction.editReply({ embeds: [ new EmbedBuilder() - .setColor(0xef4444) - .setTitle("🎡 룰렛") - .setDescription("❌ 잔액이 부족합니다."), + .setColor(0x3b82f6) + .setTitle("룰렛") + .setImage(ROULETTE_GIFS[result]) + .setFooter({ text: "룰렛이 돌아가고 있습니다..." }), ], components: [], }); - return; - } - - await updateBalance(interaction.guildId!, userId, -amount); - - const result = Math.floor(Math.random() * 37); - const colorEmoji = result === 0 ? "🟢" : RED_NUMS.has(result) ? "🔴" : "⚫"; - - const win = - betType === "zero" - ? result === 0 - : betType === "odd" - ? result !== 0 && result % 2 === 1 - : betType === "even" - ? result !== 0 && result % 2 === 0 - : betType === "red" - ? RED_NUMS.has(result) - : betType === "black" - ? result !== 0 && !RED_NUMS.has(result) - : false; + await sleep(9000); - let delta: number; - if (betType === "zero" && win) { - delta = amount * 35; - await updateBalance(interaction.guildId!, userId, amount * 36); - } else if (win) { - delta = amount; - await updateBalance(interaction.guildId!, userId, amount * 2); - } else { - delta = -amount; + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(win ? 0x22c55e : 0xef4444) + .setTitle("🎡 룰렛") + .setDescription(`> ${colorEmoji} **${result}** ${colorEmoji}`) + .addFields( + { name: "베팅", value: bet.label, inline: true }, + { name: "판정", value: win ? "🎉 승리!" : "😔 패배", inline: true }, + { name: "손익", value: fmt(delta), inline: true }, + { name: "현재 잔액", value: `${balance.toLocaleString()}원`, inline: true }, + ), + ], + }); + } finally { + endGame(userId); } - - const updated = await getUser(interaction.guildId!, userId, interaction.user.username); - const betLabel: Record = { - odd: "홀", - even: "짝", - black: "⚫ 검", - red: "🔴 빨", - zero: "🟢 0", - }; - - await interaction.deferUpdate(); - await interaction.editReply({ - embeds: [ - new EmbedBuilder() - .setColor(0x3b82f6) - .setTitle("룰렛") - .setImage(ROULETTE_GIFS[result]) - .setFooter({ text: "룰렛이 돌아가고 있습니다..." }), - ], - components: [], - }); - await sleep(9000); - - await interaction.editReply({ - embeds: [ - new EmbedBuilder() - .setColor(win ? 0x22c55e : 0xef4444) - .setTitle("🎡 룰렛") - .setDescription(`> ${colorEmoji} **${result}** ${colorEmoji}`) - .addFields( - { name: "베팅", value: betLabel[betType] ?? betType, inline: true }, - { name: "판정", value: win ? "🎉 승리!" : "😔 패배", inline: true }, - { name: "손익", value: fmt(delta), inline: true }, - { name: "현재 잔액", value: `${updated.balance.toLocaleString()}원`, inline: true }, - ), - ], - }); - activeGamblers.delete(userId); } diff --git a/casino/games/shared.test.ts b/casino/games/shared.test.ts new file mode 100644 index 0000000..04ba0d1 --- /dev/null +++ b/casino/games/shared.test.ts @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { startGame, claimGame, endGame, isGambling } from "./shared.js"; + +test("게임은 한 번만 정산된다 (버튼 연타 방지)", () => { + const id = startGame("u1"); + assert.equal(isGambling("u1"), true); + assert.equal(claimGame("u1", id), true); + assert.equal(claimGame("u1", id), false); + endGame("u1"); + assert.equal(isGambling("u1"), false); +}); + +test("예전 게임의 버튼으로는 새 게임을 정산할 수 없다", () => { + const oldId = startGame("u2"); + const newId = startGame("u2"); + assert.notEqual(oldId, newId); + assert.equal(claimGame("u2", oldId), false); + assert.equal(claimGame("u2", newId), true); + endGame("u2"); +}); + +test("버튼을 안 누르고 떠나도 만료되면 잠금이 풀린다", async () => { + const id = startGame("u3", 5); + await new Promise((r) => setTimeout(r, 15)); + assert.equal(isGambling("u3"), false); + assert.equal(claimGame("u3", id), false); +}); + +test("게임이 없으면 정산할 수 없다", () => { + assert.equal(claimGame("nobody", "1"), false); +}); diff --git a/casino/games/shared.ts b/casino/games/shared.ts index 0edf682..6e72e36 100644 --- a/casino/games/shared.ts +++ b/casino/games/shared.ts @@ -1,3 +1,5 @@ +import { ButtonInteraction, EmbedBuilder, MessageFlags } from "discord.js"; + export const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); export interface BetResult { @@ -35,10 +37,75 @@ export function fmt(n: number): string { return (n >= 0 ? "+" : "") + n.toLocaleString() + "원"; } -export const activeGamblers = new Set(); +// 진행 중인 게임. 예전엔 Set이라 버튼을 안 누르고 떠나면 재시작 전까지 영원히 "진행 중"으로 묶였다. +// 이제 만료 시각을 두고, 버튼 customId에 게임 id를 넣어 옛 메시지 버튼이나 연타로 두 번 정산되지 않게 한다. +interface ActiveGame { + id: string; + expiresAt: number; + settling: boolean; +} + +const BUTTON_GAME_TTL_MS = 5 * 60 * 1000; +const activeGames = new Map(); +let gameSeq = 0; export function isGambling(userId: string): boolean { - return activeGamblers.has(userId); + const game = activeGames.get(userId); + if (!game) return false; + if (game.expiresAt <= Date.now()) { + activeGames.delete(userId); + return false; + } + return true; +} + +// 게임 시작 — 버튼 customId에 붙일 게임 id를 돌려준다 +export function startGame(userId: string, ttlMs = BUTTON_GAME_TTL_MS): string { + const id = (++gameSeq).toString(36); + activeGames.set(userId, { id, expiresAt: Date.now() + ttlMs, settling: false }); + return id; +} + +// 버튼으로 결과를 정산하기 직전에 호출. 해당 게임이 살아 있고 아직 정산 전일 때만 true. +export function claimGame(userId: string, gameId: string): boolean { + const game = activeGames.get(userId); + if (!game || game.id !== gameId || game.settling || !isGambling(userId)) return false; + game.settling = true; + return true; +} + +export function endGame(userId: string): void { + activeGames.delete(userId); +} + +// 버튼을 누른 사람이 게임 주인인지, 그 게임이 아직 정산 전인지 확인한다. 아니면 본인에게만 안내하고 false. +export async function claimButton( + interaction: ButtonInteraction, + userId: string, + gameId: string, +): Promise { + if (interaction.user.id !== userId) { + await interaction.reply({ + content: "❌ 이 게임은 당신의 게임이 아닙니다.", + flags: MessageFlags.Ephemeral, + }); + return false; + } + if (!claimGame(userId, gameId)) { + await interaction.reply({ + content: "⌛ 이미 끝났거나 만료된 게임입니다.", + flags: MessageFlags.Ephemeral, + }); + return false; + } + return true; +} + +export function insufficientEmbed(title: string): EmbedBuilder { + return new EmbedBuilder() + .setColor(0xef4444) + .setTitle(title) + .setDescription("❌ 잔액이 부족합니다."); } export const SUITS = ["♠", "♥", "♦", "♣"]; diff --git a/casino/handler.ts b/casino/handler.ts index 2023045..a3752f3 100644 --- a/casino/handler.ts +++ b/casino/handler.ts @@ -1,4 +1,4 @@ -import { PermissionFlagsBits, Message, ChatInputCommandInteraction, GuildMember } from "discord.js"; +import { Message, ChatInputCommandInteraction, MessageFlags } from "discord.js"; import { handleAttendance, handleWork, @@ -16,221 +16,127 @@ import { isGambling, } from "./games.js"; import { getGamblingEnabled, setGamblingEnabled } from "../db.js"; -import { ctxFromMessage, ctxFromInteraction } from "../ctx.js"; +import { Ctx, ctxFromMessage, ctxFromInteraction, handleAdminToggle } from "../ctx.js"; -const GAMBLING_CMDS = new Set([ - "!코인", - "!블랙잭", - "!바카라", - "!룰렛", - "!출석", - "!일", - "!노동", - "!지원금", -]); +// 텍스트 명령 → 슬래시 명령 이름. 같은 로직을 쓰도록 텍스트 명령을 슬래시 이름으로 정규화한다. +const TEXT_TO_SLASH: Record = { + "!도박": "도박", + "!출석": "출석", + "!일": "일", + "!노동": "일", + "!잔액": "잔액", + "!지원금": "지원금", + "!랭킹": "랭킹", + "!송금": "송금", + "!코인": "코인", + "!블랙잭": "블랙잭", + "!바카라": "바카라", + "!룰렛": "룰렛", +}; -async function handleGamblingToggle(message: Message, args: string[]): Promise { - if (!message.guild) { - await message.reply("❌ 이 명령어는 서버에서만 사용할 수 있습니다."); - return; +// 도박 기능이 꺼져 있거나 게임이 진행 중이면 막는 명령 +const GAMBLING_CMDS = new Set(["코인", "블랙잭", "바카라", "룰렛", "출석", "일", "지원금"]); + +const GAMES: Record Promise> = { + 코인: handleCoinflip, + 블랙잭: handleBlackjack, + 바카라: handleBaccarat, + 룰렛: handleRoulette, +}; + +const ECONOMY: Record Promise> = { + 출석: handleAttendance, + 일: handleWork, + 잔액: handleBalance, + 지원금: handleSupport, + 랭킹: handleRanking, +}; + +// 공통 진입 검사. 통과하면 true. +// (슬래시는 기존처럼 본인에게만, 텍스트는 기존처럼 채널에 답장) +async function checkGambling(ctx: Ctx, cmd: string): Promise { + if (!GAMBLING_CMDS.has(cmd)) return true; + const notice = ctx.isInteraction ? ctx.replyPrivate : ctx.reply; + if (!(await getGamblingEnabled(ctx.guildId!))) { + await notice("🔒 현재 서버에서 도박 기능이 비활성화되어 있습니다."); + return false; + } + if (isGambling(ctx.authorId)) { + await notice("🎰 진행 중인 도박 게임이 있습니다. 게임이 끝난 후 이용해주세요."); + return false; } - const subCmd = args[0]?.toLowerCase() || ""; + return true; +} +async function handleGamblingSetting(ctx: Ctx, subCmd: string, usage: string): Promise { if (subCmd === "on" || subCmd === "off") { - if (!message.member!.permissions.has(PermissionFlagsBits.Administrator)) { - await message.reply("❌ 서버 관리자 권한이 필요합니다."); - return; - } - const enable = subCmd === "on"; - await setGamblingEnabled(message.guild.id, enable); - await message.reply( - enable ? "✅ 도박 기능이 **활성화**되었습니다." : "🔒 도박 기능이 **비활성화**되었습니다.", - ); - } else { - const enabled = await getGamblingEnabled(message.guild.id); - await message.reply( - `🎰 현재 도박 기능: ${enabled ? "**활성화** ✅" : "**비활성화** 🔒"}\n사용법: \`!도박 on\` / \`!도박 off\``, - ); + await handleAdminToggle(ctx, subCmd === "on", "도박", setGamblingEnabled); + return; } + const enabled = await getGamblingEnabled(ctx.guildId!); + await ctx.reply( + `🎰 현재 도박 기능: ${enabled ? "**활성화** ✅" : "**비활성화** 🔒"}\n사용법: ${usage}`, + ); } export async function handleCasino(message: Message): Promise { const parts = message.content.trim().split(/\s+/); - const cmd = parts[0].toLowerCase(); + const cmd = TEXT_TO_SLASH[parts[0].toLowerCase()]; + if (!cmd) return false; const args = parts.slice(1); - const ALL_CASINO_CMDS = new Set([...GAMBLING_CMDS, "!도박", "!잔액", "!랭킹", "!송금"]); - if (!message.guild) { - if (ALL_CASINO_CMDS.has(cmd)) { - await message.reply("❌ 이 명령어는 서버에서만 사용할 수 있습니다."); - return true; - } - return false; + await message.reply("❌ 이 명령어는 서버에서만 사용할 수 있습니다."); + return true; } - if (GAMBLING_CMDS.has(cmd)) { - if (message.guild && !(await getGamblingEnabled(message.guild.id))) { - await message.reply("🔒 현재 서버에서 도박 기능이 비활성화되어 있습니다."); - return true; - } - if (isGambling(message.author.id)) { - await message.reply("🎰 진행 중인 도박 게임이 있습니다. 게임이 끝난 후 이용해주세요."); - return true; - } - } + const ctx = ctxFromMessage(message); + if (!(await checkGambling(ctx, cmd))) return true; - switch (cmd) { - case "!도박": - await handleGamblingToggle(message, args); - return true; - case "!출석": - await handleAttendance(ctxFromMessage(message)); - return true; - case "!일": - case "!노동": - await handleWork(ctxFromMessage(message)); - return true; - case "!잔액": - await handleBalance(ctxFromMessage(message)); - return true; - case "!지원금": - await handleSupport(ctxFromMessage(message)); - return true; - case "!랭킹": - await handleRanking(ctxFromMessage(message)); - return true; - case "!송금": { - const mention = message.mentions.users.first(); - if (!mention) { - await message.reply("❌ 송금할 대상을 멘션해주세요. 예) `!송금 @이름 10000`"); - return true; - } - await handleTransfer( - ctxFromMessage(message), - mention.id, - mention.username, - mention.bot, - args[1], - ); + if (cmd === "도박") { + await handleGamblingSetting(ctx, args[0]?.toLowerCase() ?? "", "`!도박 on` / `!도박 off`"); + } else if (cmd === "송금") { + const mention = message.mentions.users.first(); + if (!mention) { + await message.reply("❌ 송금할 대상을 멘션해주세요. 예) `!송금 @이름 10000`"); return true; } - case "!코인": - await handleCoinflip(ctxFromMessage(message), args); - return true; - case "!블랙잭": - await handleBlackjack(ctxFromMessage(message), args); - return true; - case "!바카라": - await handleBaccarat(ctxFromMessage(message), args); - return true; - case "!룰렛": - await handleRoulette(ctxFromMessage(message), args); - return true; - default: - return false; + await handleTransfer(ctx, mention.id, mention.username, mention.bot, args[1]); + } else if (GAMES[cmd]) { + await GAMES[cmd](ctx, args); + } else { + await ECONOMY[cmd](ctx); } + return true; } export async function handleCasinoSlash(interaction: ChatInputCommandInteraction): Promise { if (!interaction.guildId) { await interaction.reply({ content: "❌ 이 명령어는 서버에서만 사용할 수 있습니다.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } const cmd = interaction.commandName; const ctx = ctxFromInteraction(interaction); + if (!(await checkGambling(ctx, cmd))) return; - const GAMBLING_SLASH = new Set(["코인", "블랙잭", "바카라", "룰렛", "출석", "일", "지원금"]); - - if (GAMBLING_SLASH.has(cmd)) { - if (!(await getGamblingEnabled(interaction.guildId))) { - await interaction.reply({ - content: "🔒 현재 서버에서 도박 기능이 비활성화되어 있습니다.", - ephemeral: true, - }); - return; - } - if (isGambling(interaction.user.id)) { - await interaction.reply({ - content: "🎰 진행 중인 도박 게임이 있습니다. 게임이 끝난 후 이용해주세요.", - ephemeral: true, - }); - return; - } - } - - switch (cmd) { - case "도박": { - const subCmd = interaction.options.getString("설정") ?? ""; - if (subCmd === "on" || subCmd === "off") { - const member = interaction.member instanceof GuildMember ? interaction.member : null; - if (!member?.permissions.has(PermissionFlagsBits.Administrator)) { - await interaction.reply({ - content: "❌ 서버 관리자 권한이 필요합니다.", - ephemeral: true, - }); - return; - } - const enable = subCmd === "on"; - await setGamblingEnabled(interaction.guildId, enable); - await interaction.reply( - enable - ? "✅ 도박 기능이 **활성화**되었습니다." - : "🔒 도박 기능이 **비활성화**되었습니다.", - ); - } else { - const enabled = await getGamblingEnabled(interaction.guildId); - await interaction.reply( - `🎰 현재 도박 기능: ${enabled ? "**활성화** ✅" : "**비활성화** 🔒"}\n사용법: \`/도박 설정:on\` / \`/도박 설정:off\``, - ); - } - break; - } - case "출석": - await handleAttendance(ctx); - break; - case "일": - await handleWork(ctx); - break; - case "잔액": - await handleBalance(ctx); - break; - case "지원금": - await handleSupport(ctx); - break; - case "랭킹": - await handleRanking(ctx); - break; - case "송금": { - const target = interaction.options.getUser("대상", true); - const amountStr = interaction.options.getString("금액", true); - await handleTransfer(ctx, target.id, target.username, target.bot, amountStr); - break; - } - case "코인": { - const amount = interaction.options.getString("금액", true); - await handleCoinflip(ctx, [amount]); - break; - } - case "블랙잭": { - const amount = interaction.options.getString("금액", true); - await handleBlackjack(ctx, [amount]); - break; - } - case "바카라": { - const amount = interaction.options.getString("금액", true); - await handleBaccarat(ctx, [amount]); - break; - } - case "룰렛": { - const amount = interaction.options.getString("금액", true); - await handleRoulette(ctx, [amount]); - break; - } + if (cmd === "도박") { + await handleGamblingSetting( + ctx, + interaction.options.getString("설정") ?? "", + "`/도박 설정:on` / `/도박 설정:off`", + ); + } else if (cmd === "송금") { + const target = interaction.options.getUser("대상", true); + const amountStr = interaction.options.getString("금액", true); + await handleTransfer(ctx, target.id, target.username, target.bot, amountStr); + } else if (GAMES[cmd]) { + await GAMES[cmd](ctx, [interaction.options.getString("금액", true)]); + } else if (ECONOMY[cmd]) { + await ECONOMY[cmd](ctx); } } diff --git a/commands.ts b/commands.ts index 83e16b7..65f6a60 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("급식") @@ -25,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(), @@ -244,6 +265,39 @@ const commands: RESTPostAPIApplicationCommandsJSONBody[] = [ .setDescription("이 서버의 알림을 전체 삭제합니다") .toJSON(), + new SlashCommandBuilder() + .setName("gif등록") + .setDescription("키워드에 GIF를 등록합니다") + .addStringOption((opt) => + opt + .setName("키워드") + .setDescription("GIF를 부를 키워드 (예: @충동적 구매@)") + .setRequired(true), + ) + .addAttachmentOption((opt) => + opt.setName("파일").setDescription("등록할 GIF 파일").setRequired(false), + ) + .addStringOption((opt) => + opt + .setName("주소") + .setDescription("GIF 이미지 직링크 (파일을 첨부하지 않을 때)") + .setRequired(false), + ) + .toJSON(), + + new SlashCommandBuilder() + .setName("gif목록") + .setDescription("등록된 GIF 키워드 목록을 확인합니다") + .toJSON(), + + new SlashCommandBuilder() + .setName("gif삭제") + .setDescription("등록된 GIF 키워드를 삭제합니다") + .addStringOption((opt) => + opt.setName("키워드").setDescription("삭제할 키워드").setRequired(true), + ) + .toJSON(), + new SlashCommandBuilder() .setName("명령어") .setDescription("사용 가능한 명령어 목록을 확인합니다") diff --git a/ctx.ts b/ctx.ts index 0bb3b06..f905f25 100644 --- a/ctx.ts +++ b/ctx.ts @@ -5,12 +5,23 @@ import { GuildTextBasedChannel, VoiceBasedChannel, Message, + MessageFlags, + PermissionFlagsBits, ChatInputCommandInteraction, InteractionReplyOptions, } from "discord.js"; +// 텍스트 명령은 디스코드 API상 "나만 보기(ephemeral)"가 불가능해서, 잠깐 보여준 뒤 지운다 +const PRIVATE_REPLY_TTL_MS = 5_000; + export interface Ctx { reply(opts: string | BaseMessageOptions): Promise; + // 슬래시 명령: 본인에게만 보이는 ephemeral 응답 / 텍스트 명령: 몇 초 뒤 자동 삭제되는 답장 + replyPrivate(content: string): Promise; + // 외부 API처럼 오래 걸릴 수 있는 작업 전에 호출 — 슬래시는 3초 응답 제한을 피하고, 텍스트는 입력 중 표시 + defer(): Promise; + isInteraction: boolean; + isAdmin: boolean; guildId: string | null; guild: Guild | null; authorId: string; @@ -24,6 +35,17 @@ export function ctxFromMessage(message: Message): Ctx { const member = message.member as GuildMember | null; return { reply: (opts) => message.reply(opts as any), + replyPrivate: async (content) => { + const sent = await message.reply({ content, allowedMentions: { repliedUser: false } }); + setTimeout(() => void sent.delete().catch(() => {}), PRIVATE_REPLY_TTL_MS).unref(); + return sent; + }, + defer: async () => { + // 입력 중 표시는 부가 효과라 기다리지 않는다 (응답 지연을 늘리지 않도록) + if ("sendTyping" in message.channel) void message.channel.sendTyping().catch(() => {}); + }, + isInteraction: false, + isAdmin: member?.permissions.has(PermissionFlagsBits.Administrator) ?? false, guildId: message.guild?.id ?? null, guild: message.guild, authorId: message.author.id, @@ -36,11 +58,31 @@ export function ctxFromMessage(message: Message): Ctx { export function ctxFromInteraction(interaction: ChatInputCommandInteraction): Ctx { const member = interaction.member instanceof GuildMember ? interaction.member : null; + // defer 후 첫 응답은 "생각 중..." 메시지를 채우고(editReply), 그 뒤 응답은 새 메시지(followUp)로 보낸다 + let answered = false; return { reply: (opts) => { - if (typeof opts === "string") return interaction.reply({ content: opts }); - return interaction.reply(opts as InteractionReplyOptions); + const o = typeof opts === "string" ? { content: opts } : opts; + if (interaction.deferred && !answered) { + answered = true; + return interaction.editReply(o); + } + if (interaction.deferred || interaction.replied) return interaction.followUp(o); + answered = true; + return interaction.reply(o as InteractionReplyOptions); + }, + replyPrivate: (content) => { + const o = { content, flags: MessageFlags.Ephemeral } as const; + if (interaction.deferred || interaction.replied) return interaction.followUp(o); + answered = true; + return interaction.reply(o); }, + defer: async () => { + if (!interaction.deferred && !interaction.replied) await interaction.deferReply(); + }, + isInteraction: true, + // interaction.member는 캐시 상태에 따라 GuildMember가 아닐 수 있어서 memberPermissions로 판정한다 + isAdmin: interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) ?? false, guildId: interaction.guildId, guild: interaction.guild, authorId: interaction.user.id, @@ -50,3 +92,26 @@ export function ctxFromInteraction(interaction: ChatInputCommandInteraction): Ct voiceChannel: member?.voice?.channel ?? null, }; } + +// 서버 관리자 전용 기능 on/off 공통 처리 (급식, 도박) +export async function handleAdminToggle( + ctx: Ctx, + enable: boolean, + featureName: string, + save: (guildId: string, enabled: boolean) => Promise, +): Promise { + if (!ctx.guildId) { + await ctx.replyPrivate("❌ 이 명령어는 서버에서만 사용할 수 있습니다."); + return; + } + if (!ctx.isAdmin) { + await ctx.replyPrivate("❌ 서버 관리자 권한이 필요합니다."); + return; + } + await save(ctx.guildId, enable); + await ctx.reply( + enable + ? `✅ ${featureName} 기능이 **활성화**되었습니다.` + : `🔒 ${featureName} 기능이 **비활성화**되었습니다.`, + ); +} diff --git a/db.ts b/db.ts index b894923..90e25bc 100644 --- a/db.ts +++ b/db.ts @@ -28,6 +28,8 @@ export interface Schedule extends RowDataPacket { is_active: number; } +const DEFAULT_BALANCE = 150000; + const pool = mysql.createPool({ host: process.env.MYSQL_HOST, port: Number(process.env.MYSQL_PORT) || 3306, @@ -35,34 +37,87 @@ const pool = mysql.createPool({ password: process.env.MYSQL_PASSWORD, database: process.env.MYSQL_DATABASE, waitForConnections: true, + // last_* 컬럼은 UTC 문자열로 저장하므로 읽을 때도 UTC로 해석한다 (호스트 TZ가 KST여도 쿨다운 계산이 맞도록) + timezone: "Z", + // DATE(target_date)는 시간대 변환 없이 "YYYY-MM-DD" 문자열로 받는다 + dateStrings: ["DATE"], connectionLimit: 10, + // 유휴 커넥션이 NAT/방화벽에 끊겨서 첫 쿼리가 에러 나는 걸 막는다 + enableKeepAlive: true, + keepAliveInitialDelay: 10_000, }); +interface NameRow extends RowDataPacket { + name: string; +} + +// 예전에는 부팅마다 ALTER TABLE을 전부 던지고 실패를 무시했는데(기본키 재생성은 매번 성공해서 +// 테이블 전체를 다시 만들었다), 이제 스키마를 한 번 읽어서 필요한 변경만 적용한다. +async function getColumns(table: string): Promise> { + const [rows] = await pool.execute( + `SELECT COLUMN_NAME AS name FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`, + [table], + ); + return new Set(rows.map((r) => r.name)); +} + +async function getIndexColumns(table: string, index: string): Promise { + const [rows] = await pool.execute( + `SELECT COLUMN_NAME AS name FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? + ORDER BY SEQ_IN_INDEX`, + [table, index], + ); + return rows.map((r) => r.name); +} + +async function addMissingColumns(table: string, columns: Record): Promise { + const existing = await getColumns(table); + for (const [name, ddl] of Object.entries(columns)) { + if (!existing.has(name)) await pool.execute(`ALTER TABLE ${table} ADD COLUMN ${name} ${ddl}`); + } +} + +async function addIndexIfMissing(table: string, index: string, columns: string): Promise { + if ((await getIndexColumns(table, index)).length === 0) { + await pool.execute(`ALTER TABLE ${table} ADD INDEX ${index} (${columns})`); + } +} + async function init(): Promise { await pool.execute(` CREATE TABLE IF NOT EXISTS users ( id VARCHAR(30) NOT NULL, guild_id VARCHAR(30) NOT NULL DEFAULT '', username VARCHAR(100) NOT NULL, - balance BIGINT NOT NULL DEFAULT 150000, + balance BIGINT NOT NULL DEFAULT ${DEFAULT_BALANCE}, last_attendance DATETIME NULL, last_work DATETIME NULL, last_support DATETIME NULL, - PRIMARY KEY (id, guild_id) + PRIMARY KEY (id, guild_id), + INDEX idx_guild_balance (guild_id, balance) ) `); - try { - await pool.execute(`ALTER TABLE users ADD COLUMN guild_id VARCHAR(30) NOT NULL DEFAULT ''`); - } catch (_) {} - try { - await pool.execute(`ALTER TABLE users DROP PRIMARY KEY, ADD PRIMARY KEY (id, guild_id)`); - } catch (_) {} + await addMissingColumns("users", { guild_id: "VARCHAR(30) NOT NULL DEFAULT ''" }); + const pk = await getIndexColumns("users", "PRIMARY"); + if (pk.join(",") !== "id,guild_id") { + await pool.execute( + `ALTER TABLE users ${pk.length ? "DROP PRIMARY KEY," : ""} ADD PRIMARY KEY (id, guild_id)`, + ); + } + // !랭킹은 guild_id로 거르고 balance로 정렬하는데, 기본키(id, guild_id)로는 풀스캔이 된다 + await addIndexIfMissing("users", "idx_guild_balance", "guild_id, balance"); + await pool.execute(` CREATE TABLE IF NOT EXISTS guild_settings ( guild_id VARCHAR(30) NOT NULL PRIMARY KEY, - gambling_enabled TINYINT(1) NOT NULL DEFAULT 1 + gambling_enabled TINYINT(1) NOT NULL DEFAULT 1, + meal_enabled TINYINT(1) NOT NULL DEFAULT 1 ) `); + await addMissingColumns("guild_settings", { meal_enabled: "TINYINT(1) NOT NULL DEFAULT 1" }); + await pool.execute(` CREATE TABLE IF NOT EXISTS schedules ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -79,41 +134,58 @@ async function init(): Promise { is_active TINYINT(1) NOT NULL DEFAULT 1 ) `); - try { - await pool.execute( - `ALTER TABLE schedules ADD COLUMN guild_id VARCHAR(30) NOT NULL DEFAULT '' AFTER id`, - ); - } catch (_) {} - try { - await pool.execute( - `ALTER TABLE schedules ADD COLUMN schedule_type VARCHAR(20) NOT NULL DEFAULT 'daily'`, - ); - } catch (_) {} - try { - await pool.execute(`ALTER TABLE schedules ADD COLUMN weekdays VARCHAR(20) NULL`); - } catch (_) {} - try { - await pool.execute(`ALTER TABLE schedules ADD COLUMN day_of_month TINYINT NULL`); - } catch (_) {} - try { - await pool.execute(`ALTER TABLE schedules ADD COLUMN target_date DATE NULL`); - } catch (_) {} - try { - await pool.execute(`ALTER TABLE schedules ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1`); - } catch (_) {} + await addMissingColumns("schedules", { + guild_id: "VARCHAR(30) NOT NULL DEFAULT '' AFTER id", + schedule_type: "VARCHAR(20) NOT NULL DEFAULT 'daily'", + weekdays: "VARCHAR(20) NULL", + day_of_month: "TINYINT NULL", + target_date: "DATE NULL", + is_active: "TINYINT(1) NOT NULL DEFAULT 1", + }); + + await pool.execute(` + CREATE TABLE IF NOT EXISTS gif_triggers ( + id INT AUTO_INCREMENT PRIMARY KEY, + guild_id VARCHAR(30) NOT NULL, + keyword VARCHAR(100) NOT NULL, + data MEDIUMBLOB NOT NULL, + content_type VARCHAR(50) NOT NULL DEFAULT 'image/gif', + created_by VARCHAR(30) NOT NULL, + UNIQUE KEY uniq_guild_keyword (guild_id, keyword) + ) + `); } +// 흔한 경우(이미 있는 유저)는 SELECT 한 번으로 끝낸다. 예전엔 매번 INSERT IGNORE + SELECT 두 번 왕복. async function getUser(guildId: string, id: string, username: string): Promise { - await pool.execute(`INSERT IGNORE INTO users (id, guild_id, username) VALUES (?, ?, ?)`, [ + const [rows] = await pool.execute(`SELECT * FROM users WHERE id = ? AND guild_id = ?`, [ id, guildId, - username, ]); - const [rows] = await pool.execute(`SELECT * FROM users WHERE id = ? AND guild_id = ?`, [ + if (rows[0]) { + // 닉네임을 바꾼 유저가 랭킹에 옛 이름으로 남지 않게 — 응답을 기다릴 필요는 없다 + if (rows[0].username !== username) { + pool + .execute(`UPDATE users SET username = ? WHERE id = ? AND guild_id = ?`, [ + username, + id, + guildId, + ]) + .catch((e: Error) => console.error("[DB] 닉네임 갱신 실패:", e.message)); + } + return rows[0]; + } + + await pool.execute(`INSERT IGNORE INTO users (id, guild_id, username) VALUES (?, ?, ?)`, [ id, guildId, + username, ]); - return rows[0]; + const [created] = await pool.execute( + `SELECT * FROM users WHERE id = ? AND guild_id = ?`, + [id, guildId], + ); + return created[0]; } async function updateBalance(guildId: string, id: string, delta: number): Promise { @@ -124,24 +196,88 @@ async function updateBalance(guildId: string, id: string, delta: number): Promis ]); } -async function setField( +interface BalanceRow extends RowDataPacket { + balance: number; +} + +async function getBalance(guildId: string, id: string): Promise { + const [rows] = await pool.execute( + `SELECT balance FROM users WHERE id = ? AND guild_id = ?`, + [id, guildId], + ); + return rows[0]?.balance ?? 0; +} + +// updateBalance + getUser을 따로 부르면 매번 왕복이 3번(UPDATE, SELECT, ...)이라, +// 결과 잔액이 바로 필요한 호출부를 위해 UPDATE 후 잔액만 가볍게 다시 읽는다. +async function updateBalanceAndGet(guildId: string, id: string, delta: number): Promise { + await updateBalance(guildId, id, delta); + return getBalance(guildId, id); +} + +// 잔액이 minBalance 이상일 때만 delta를 반영한다 (조건 검사와 갱신이 한 쿼리라 동시 요청에도 안전). +// 성공하면 새 잔액, 잔액이 모자라면 null. +async function tryAdjustBalance( guildId: string, id: string, - field: "last_attendance" | "last_work" | "last_support", - value: string, + delta: number, + minBalance: number, +): Promise { + const [result] = await pool.execute( + `UPDATE users SET balance = balance + ? WHERE id = ? AND guild_id = ? AND balance >= ?`, + [delta, id, guildId, minBalance], + ); + if (result.affectedRows === 0) return null; + return getBalance(guildId, id); +} + +type RewardField = "last_attendance" | "last_work" | "last_support"; +const REWARD_FIELDS: readonly RewardField[] = ["last_attendance", "last_work", "last_support"]; + +// 쿨다운 보상(출석/노동/지원금) 지급. field가 cutoff 이전일 때만 지급하고 시각을 기록한다. +// 조건 확인과 지급을 한 UPDATE로 처리해서, 명령을 연타해도 보상이 두 번 들어가지 않는다. +async function claimReward( + guildId: string, + id: string, + field: RewardField, + reward: number, + now: string, + cutoff: string, + opts: { requireZeroBalance?: boolean } = {}, +): Promise { + if (!REWARD_FIELDS.includes(field)) throw new Error(`Invalid field: ${field}`); + const [result] = await pool.execute( + `UPDATE users SET balance = balance + ?, ${field} = ? + WHERE id = ? AND guild_id = ? AND (${field} IS NULL OR ${field} <= ?) + ${opts.requireZeroBalance ? "AND balance <= 0" : ""}`, + [reward, now, id, guildId, cutoff], + ); + if (result.affectedRows === 0) return null; + return getBalance(guildId, id); +} + +// 받는 사람이 아직 DB에 없으면 기본 잔액 + 입금액으로 만들고, 있으면 입금만 한다 (왕복 1번) +async function creditUser( + guildId: string, + id: string, + username: string, + amount: number, ): Promise { - const allowed = ["last_attendance", "last_work", "last_support"]; - if (!allowed.includes(field)) throw new Error(`Invalid field: ${field}`); - await pool.execute(`UPDATE users SET ${field} = ? WHERE id = ? AND guild_id = ?`, [ - value, - id, - guildId, - ]); + await pool.execute( + `INSERT INTO users (id, guild_id, username, balance) VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE balance = balance + ?`, + [id, guildId, username, DEFAULT_BALANCE + amount, amount], + ); +} + +interface RankingRow extends RowDataPacket { + username: string; + balance: number; } -async function getTopUsers(guildId: string, limit = 10): Promise { - const [rows] = await pool.execute( - `SELECT * FROM users WHERE guild_id = ? ORDER BY balance DESC LIMIT ?`, +async function getTopUsers(guildId: string, limit = 10): Promise { + const [rows] = await pool.execute( + `SELECT username, balance FROM users WHERE guild_id = ? ORDER BY balance DESC LIMIT ?`, [guildId, limit], ); return rows; @@ -212,24 +348,106 @@ async function deleteAllSchedules(guildId: string): Promise { return result.affectedRows; } +type GuildFlag = "gambling_enabled" | "meal_enabled"; +const GUILD_FLAGS: readonly GuildFlag[] = ["gambling_enabled", "meal_enabled"]; + interface GuildSettingsRow extends RowDataPacket { gambling_enabled: number; + meal_enabled: number; } -async function getGamblingEnabled(guildId: string): Promise { +// 명령마다 조회되는데 관리자가 토글할 때만 바뀌므로, 쓰기 시점에 직접 갱신하는 캐시로 충분하다. +// 한 번 조회할 때 두 플래그를 같이 읽어서 도박/급식 첫 조회가 각각 DB를 치지 않게 한다. +const guildSettingsCache = new Map>(); + +async function getGuildFlag(guildId: string, flag: GuildFlag): Promise { + const cached = guildSettingsCache.get(guildId); + if (cached) return cached[flag]; + const [rows] = await pool.execute( - `SELECT gambling_enabled FROM guild_settings WHERE guild_id = ?`, + `SELECT gambling_enabled, meal_enabled FROM guild_settings WHERE guild_id = ?`, [guildId], ); - return rows.length === 0 ? true : rows[0].gambling_enabled === 1; + const settings = { + gambling_enabled: rows[0] ? rows[0].gambling_enabled === 1 : true, + meal_enabled: rows[0] ? rows[0].meal_enabled === 1 : true, + }; + guildSettingsCache.set(guildId, settings); + return settings[flag]; } -async function setGamblingEnabled(guildId: string, enabled: boolean): Promise { +async function setGuildFlag(guildId: string, flag: GuildFlag, enabled: boolean): Promise { + if (!GUILD_FLAGS.includes(flag)) throw new Error(`Invalid flag: ${flag}`); await pool.execute( - `INSERT INTO guild_settings (guild_id, gambling_enabled) VALUES (?, ?) - ON DUPLICATE KEY UPDATE gambling_enabled = VALUES(gambling_enabled)`, + `INSERT INTO guild_settings (guild_id, ${flag}) VALUES (?, ?) + ON DUPLICATE KEY UPDATE ${flag} = VALUES(${flag})`, [guildId, enabled ? 1 : 0], ); + const cached = guildSettingsCache.get(guildId); + if (cached) cached[flag] = enabled; +} + +const getGamblingEnabled = (guildId: string) => getGuildFlag(guildId, "gambling_enabled"); +const setGamblingEnabled = (guildId: string, enabled: boolean) => + setGuildFlag(guildId, "gambling_enabled", enabled); +const getMealEnabled = (guildId: string) => getGuildFlag(guildId, "meal_enabled"); +const setMealEnabled = (guildId: string, enabled: boolean) => + setGuildFlag(guildId, "meal_enabled", enabled); + +interface GifTriggerRow extends RowDataPacket { + guild_id: string; + keyword: string; +} + +interface GifDataRow extends RowDataPacket { + data: Buffer; + content_type: string; +} + +// 반환값 true = 기존 키워드를 덮어썼음 (mysql은 UPDATE 시 affectedRows를 2로 준다) +async function addGifTrigger( + guildId: string, + keyword: string, + data: Buffer, + contentType: string, + createdBy: string, +): Promise { + const [result] = await pool.execute( + `INSERT INTO gif_triggers (guild_id, keyword, data, content_type, created_by) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE data = VALUES(data), content_type = VALUES(content_type), created_by = VALUES(created_by)`, + [guildId, keyword, data, contentType, createdBy], + ); + return result.affectedRows === 2; +} + +async function getGifTrigger(guildId: string, keyword: string): Promise { + const [rows] = await pool.execute( + `SELECT data, content_type FROM gif_triggers WHERE guild_id = ? AND keyword = ?`, + [guildId, keyword], + ); + return rows[0] ?? null; +} + +async function getAllGifKeywords(): Promise { + const [rows] = await pool.execute(`SELECT guild_id, keyword FROM gif_triggers`); + return rows; +} + +async function getGifKeywords(guildId: string): Promise { + const [rows] = await pool.execute( + `SELECT keyword FROM gif_triggers WHERE guild_id = ? ORDER BY id`, + [guildId], + ); + return rows.map((r) => r.keyword); +} + +async function deleteGifTrigger(guildId: string, keyword: string): Promise { + const [result] = await pool.execute( + `DELETE FROM gif_triggers WHERE guild_id = ? AND keyword = ?`, + [guildId, keyword], + ); + return result.affectedRows > 0; } async function ping(): Promise { @@ -242,8 +460,12 @@ export { init, ping, getUser, + getBalance, updateBalance, - setField, + updateBalanceAndGet, + tryAdjustBalance, + claimReward, + creditUser, getTopUsers, addSchedule, deactivateSchedule, @@ -253,4 +475,11 @@ export { deleteAllSchedules, getGamblingEnabled, setGamblingEnabled, + getMealEnabled, + setMealEnabled, + addGifTrigger, + getGifTrigger, + getAllGifKeywords, + getGifKeywords, + deleteGifTrigger, }; diff --git a/gif/handler.ts b/gif/handler.ts new file mode 100644 index 0000000..ccd85c5 --- /dev/null +++ b/gif/handler.ts @@ -0,0 +1,418 @@ +import net from "node:net"; +import { lookup } from "node:dns/promises"; +import { Message, AttachmentBuilder, ChatInputCommandInteraction, MessageFlags } from "discord.js"; +import { + addGifTrigger, + getGifTrigger, + getAllGifKeywords, + getGifKeywords, + deleteGifTrigger, +} from "../db.js"; + +const CMD_ADD = "!gif등록"; +const CMD_LIST = "!gif목록"; +const CMD_DEL = "!gif삭제"; +const MAX_BYTES = 12 * 1024 * 1024; +const MAX_KEYWORD = 100; +const EXT: Record = { + "image/gif": "gif", + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", +}; +const USAGE = `❌ 사용법: \`${CMD_ADD} 키워드 URL\` 또는 \`${CMD_ADD} 키워드\` + GIF 파일 첨부`; + +// ponytail: 단일 인스턴스 전제. 봇을 여러 대 띄우면 한쪽 등록이 다른 쪽 캐시에 안 퍼진다 — 그때 TTL 재조회나 pub/sub으로 올린다. +const keywords = new Map>(); + +// 키워드 매칭은 메시지마다 도는데 실제 GIF blob은 트리거될 때만 필요해서 별도 캐시. +// TTL 없이 등록/삭제 시점에 직접 무효화한다 — 데이터가 그때만 바뀌기 때문. +// 파일 하나가 최대 12MB라 개수 제한 없이 두면 메모리가 계속 불어나므로, 총 용량 기준 LRU로 자른다. +const BLOB_CACHE_MAX_BYTES = 64 * 1024 * 1024; + +interface GifBlob { + data: Buffer; + contentType: string; +} + +class BlobCache { + private readonly map = new Map(); + private bytes = 0; + + get(key: string): GifBlob | undefined { + const hit = this.map.get(key); + if (hit) { + // 최근 사용한 걸 맨 뒤로 보내서 가장 오래 안 쓴 것부터 버려지게 한다 + this.map.delete(key); + this.map.set(key, hit); + } + return hit; + } + + set(key: string, blob: GifBlob): void { + this.delete(key); + if (blob.data.byteLength > BLOB_CACHE_MAX_BYTES) return; + this.map.set(key, blob); + this.bytes += blob.data.byteLength; + for (const [oldKey] of this.map) { + if (this.bytes <= BLOB_CACHE_MAX_BYTES) break; + this.delete(oldKey); + } + } + + delete(key: string): void { + const old = this.map.get(key); + if (!old) return; + this.bytes -= old.data.byteLength; + this.map.delete(key); + } + + clear(): void { + this.map.clear(); + this.bytes = 0; + } +} + +const gifBlobCache = new BlobCache(); + +function gifCacheKey(guildId: string, keyword: string): string { + return `${guildId}:${keyword}`; +} + +export async function initGifCache(): Promise { + keywords.clear(); + gifBlobCache.clear(); + for (const row of await getAllGifKeywords()) { + const set = keywords.get(row.guild_id) ?? new Set(); + set.add(row.keyword); + keywords.set(row.guild_id, set); + } +} + +export type ParseResult = { keyword: string; url: string } | { error: string }; + +function validate(keyword: string, url: string | undefined): ParseResult { + if (!keyword) return { error: USAGE }; + if (keyword.length > MAX_KEYWORD) + return { error: `❌ 키워드는 ${MAX_KEYWORD}자 이하여야 합니다.` }; + if (/[\r\n]/.test(keyword)) return { error: "❌ 키워드에 줄바꿈은 쓸 수 없습니다." }; + // 문장 중간도 잡기 때문에 1글자 키워드는 아무 메시지에나 걸린다 + if (keyword.length < 2) return { error: "❌ 키워드는 2자 이상이어야 합니다." }; + if (keyword.startsWith("!")) + return { error: "❌ `!`로 시작하는 키워드는 명령어와 겹쳐서 쓸 수 없습니다." }; + if (!url) return { error: USAGE }; + if (!/^https:\/\//i.test(url)) + return { error: "❌ `https://`로 시작하는 주소만 등록할 수 있습니다." }; + return { keyword, url }; +} + +export function parseAdd(rest: string, attachmentUrl?: string): ParseResult { + const trimmed = rest.trim(); + if (attachmentUrl) return validate(trimmed, attachmentUrl); + + const match = trimmed.match(/^(.*\S)\s+(\S+)$/); + if (!match) return { error: USAGE }; + return validate(match[1].trim(), match[2]); +} + +// 등록 URL은 서버 안에서 봇이 직접 열기 때문에(SSRF) 내부망 주소를 먼저 걸러낸다 +export function isBlockedIp(ip: string): boolean { + const addr = ip.replace(/^\[|\]$/g, "").toLowerCase(); + + if (net.isIPv4(addr)) { + const [a, b] = addr.split(".").map(Number); + if (a === 0 || a === 10 || a === 127) return true; // unspecified / private / loopback + if (a === 169 && b === 254) return true; // link-local + 클라우드 메타데이터 + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 192 && b === 0) return true; // IETF 프로토콜 할당 (192.0.0/24 포함) + if (a === 198 && (b === 18 || b === 19)) return true; // 벤치마크 대역 + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + if (a >= 224) return true; // multicast / reserved + return false; + } + + // IPv4-mapped/translated 주소는 v4 규칙으로 되돌려서 판정한다 (::ffff:127.0.0.1, ::ffff:7f00:1) + if (addr.startsWith("::ffff:")) { + const rest = addr.slice(7); + if (net.isIPv4(rest)) return isBlockedIp(rest); + const hex = rest.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (hex) { + const n = ((parseInt(hex[1], 16) << 16) | parseInt(hex[2], 16)) >>> 0; + return isBlockedIp([n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".")); + } + return true; + } + if (addr.startsWith("64:ff9b:")) return true; // NAT64 변환 프리픽스 + if (addr === "::" || addr === "::1") return true; + if (/^f[cd]/.test(addr)) return true; // fc00::/7 unique local + if (/^fe[89ab]/.test(addr)) return true; // fe80::/10 link-local + return false; +} + +async function checkPublicHost(raw: string): Promise { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return "❌ 올바른 URL이 아닙니다."; + } + if (parsed.protocol !== "https:") return "❌ `https://` 주소만 등록할 수 있습니다."; + + const host = parsed.hostname.replace(/^\[|\]$/g, ""); + let addresses: string[]; + if (net.isIP(host)) { + addresses = [host]; + } else { + try { + addresses = (await lookup(host, { all: true })).map((r) => r.address); + } catch { + return "❌ 주소를 찾을 수 없습니다."; + } + } + if (addresses.length === 0 || addresses.some(isBlockedIp)) { + return "❌ 내부/비공개 주소는 등록할 수 없습니다."; + } + return null; +} + +// ponytail: DNS 재바인딩(검사 직후 IP가 바뀌는 경우)까지는 안 막는다 — 그건 IP 고정 dispatcher가 필요. +// 홉마다 재검사 + https 강제로 현실적인 내부망 접근은 막힌다. +async function safeFetch(url: string): Promise { + let current = url; + for (let hop = 0; hop < 4; hop++) { + const blocked = await checkPublicHost(current); + if (blocked) return { error: blocked }; + + const res = await fetch(current, { redirect: "manual" }); + if (res.status < 300 || res.status >= 400) return res; + + const location = res.headers.get("location"); + if (!location) return { error: "❌ 파일을 받지 못했습니다. (리다이렉트 응답이 이상합니다)" }; + current = new URL(location, current).toString(); + } + return { error: "❌ 리다이렉트가 너무 많습니다." }; +} + +// content-length는 없거나 거짓말일 수 있으므로, 받는 도중에 재서 넘으면 바로 연결을 끊는다 +export async function readCapped(res: Response): Promise { + if (!res.body) return null; + const reader = res.body.getReader(); + const chunks: Buffer[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_BYTES) { + await reader.cancel(); + return null; + } + chunks.push(Buffer.from(value)); + } + return Buffer.concat(chunks); +} + +async function download( + url: string, +): Promise<{ data: Buffer; contentType: string } | { error: string }> { + const res = await safeFetch(url); + if ("error" in res) return res; + if (!res.ok) + return { error: `❌ 파일을 받지 못했습니다. (HTTP ${res.status}) 링크가 만료됐을 수 있어요.` }; + + const contentType = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase(); + if (!contentType.startsWith("image/")) { + await res.body?.cancel(); + return { + error: + "❌ 이미지가 아닙니다. Tenor/Imgur **페이지 주소** 말고 이미지 직링크(.gif)를 넣거나 파일을 첨부해주세요.", + }; + } + + const declared = Number(res.headers.get("content-length")); + if (declared > MAX_BYTES) { + await res.body?.cancel(); + return { error: "❌ 파일이 너무 큽니다. (최대 12MB)" }; + } + + const data = await readCapped(res); + if (data === null) return { error: "❌ 파일이 너무 큽니다. (최대 12MB)" }; + if (data.byteLength === 0) return { error: "❌ 빈 파일입니다." }; + + return { data, contentType }; +} + +async function registerGif( + guildId: string, + authorId: string, + parsed: ParseResult, +): Promise { + if ("error" in parsed) return parsed.error; + + const file = await download(parsed.url); + if ("error" in file) return file.error; + + const replaced = await addGifTrigger( + guildId, + parsed.keyword, + file.data, + file.contentType, + authorId, + ); + + const set = keywords.get(guildId) ?? new Set(); + set.add(parsed.keyword); + keywords.set(guildId, set); + gifBlobCache.set(gifCacheKey(guildId, parsed.keyword), { + data: file.data, + contentType: file.contentType, + }); + + const kb = Math.round(file.data.byteLength / 1024); + return replaced + ? `♻️ \`${parsed.keyword}\` GIF를 교체했습니다. (${kb}KB)` + : `✅ \`${parsed.keyword}\` 입력하면 이 GIF를 보낼게요. (${kb}KB)`; +} + +async function listGifs(guildId: string): Promise { + const list = await getGifKeywords(guildId); + return list.length === 0 + ? `📭 등록된 GIF가 없습니다. \`${CMD_ADD} 키워드 URL\`로 등록하세요.` + : `🖼️ **등록된 GIF 키워드**\n${list.map((k) => `• \`${k}\``).join("\n")}`; +} + +async function removeGif(guildId: string, keyword: string): Promise { + if (!keyword) return `❌ 사용법: \`${CMD_DEL} 키워드\``; + const deleted = await deleteGifTrigger(guildId, keyword); + if (deleted) { + keywords.get(guildId)?.delete(keyword); + gifBlobCache.delete(gifCacheKey(guildId, keyword)); + } + return deleted + ? `✅ \`${keyword}\` GIF를 삭제했습니다.` + : `❌ \`${keyword}\` 키워드를 찾을 수 없습니다.`; +} + +function say(message: Message, content: string): Promise { + return message.reply({ content, allowedMentions: { parse: [] } }); +} + +// ponytail: 서버당 키워드 수가 적어서 그냥 훑는다. 수백 개가 되면 그때 Aho-Corasick 같은 걸 올린다. +// 모든 메시지마다 도는 경로라 배열을 새로 만들지 않고 최대 4개 찾으면 바로 멈춘다 +export function findKeywords(set: Set | undefined, content: string): string[] { + const hits: string[] = []; + if (!set) return hits; + for (const keyword of set) { + if (content.includes(keyword)) { + hits.push(keyword); + if (hits.length === 4) break; + } + } + return hits; +} + +async function sendGif(message: Message, guildId: string, keyword: string): Promise { + const cacheKey = gifCacheKey(guildId, keyword); + let row = gifBlobCache.get(cacheKey); + if (!row) { + const dbRow = await getGifTrigger(guildId, keyword); + if (!dbRow) { + keywords.get(guildId)?.delete(keyword); + return false; + } + row = { data: dbRow.data, contentType: dbRow.content_type }; + gifBlobCache.set(cacheKey, row); + } + const ext = EXT[row.contentType] ?? "gif"; + await message.reply({ files: [new AttachmentBuilder(row.data, { name: `gif.${ext}` })] }); + return true; +} + +export async function handleGif(message: Message): Promise { + if (!message.guild) return false; + + const guildId = message.guild.id; + const content = message.content.trim(); + + try { + if (content === CMD_LIST) { + await say(message, await listGifs(guildId)); + return true; + } + + if (content.startsWith(CMD_DEL)) { + await say(message, await removeGif(guildId, content.slice(CMD_DEL.length).trim())); + return true; + } + + if (content.startsWith(CMD_ADD)) { + const parsed = parseAdd(content.slice(CMD_ADD.length), message.attachments.first()?.url); + await say(message, await registerGif(guildId, message.author.id, parsed)); + return true; + } + + return await handleGifTrigger(message, guildId); + } catch (err) { + console.error("[Gif]", err); + await say(message, "❌ GIF 처리 중 오류가 발생했습니다.").catch(() => {}); + return true; + } +} + +// 등록/목록/삭제 명령이 아니면 키워드 매칭만 한다 — 대부분의 일반 메시지가 타는 경로 +async function handleGifTrigger(message: Message, guildId: string): Promise { + let sent = false; + for (const keyword of findKeywords(keywords.get(guildId), message.content)) { + if (await sendGif(message, guildId, keyword)) sent = true; + } + return sent; +} + +export async function handleGifSlash(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + if (!guildId) { + await interaction.reply({ + content: "❌ 이 명령어는 서버에서만 사용할 수 있습니다.", + flags: MessageFlags.Ephemeral, + }); + return; + } + + try { + if (interaction.commandName === "gif목록") { + await interaction.reply({ + content: await listGifs(guildId), + allowedMentions: { parse: [] }, + }); + return; + } + + if (interaction.commandName === "gif삭제") { + await interaction.reply({ + content: await removeGif(guildId, interaction.options.getString("키워드", true).trim()), + allowedMentions: { parse: [] }, + }); + return; + } + + // 다운로드가 3초를 넘길 수 있어서 먼저 defer + await interaction.deferReply(); + const keyword = interaction.options.getString("키워드", true).trim(); + const url = + interaction.options.getAttachment("파일")?.url ?? interaction.options.getString("주소"); + const text = await registerGif( + guildId, + interaction.user.id, + validate(keyword, url ?? undefined), + ); + await interaction.editReply({ content: text, allowedMentions: { parse: [] } }); + } catch (err) { + console.error("[Gif]", err); + const content = "❌ GIF 처리 중 오류가 발생했습니다."; + const done = interaction.deferred || interaction.replied; + await (done ? interaction.editReply({ content }) : interaction.reply({ content })).catch( + () => {}, + ); + } +} diff --git a/gif/parse.test.ts b/gif/parse.test.ts new file mode 100644 index 0000000..d2c90a7 --- /dev/null +++ b/gif/parse.test.ts @@ -0,0 +1,132 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseAdd, isBlockedIp, readCapped, findKeywords } from "./handler.js"; + +test("공백이 들어간 키워드 + URL", () => { + assert.deepEqual(parseAdd(" @충동적 구매@ https://x.test/a.gif"), { + keyword: "@충동적 구매@", + url: "https://x.test/a.gif", + }); +}); + +test("첨부파일이 있으면 나머지 전체가 키워드", () => { + assert.deepEqual(parseAdd(" @충동적 구매@ ", "https://cdn.test/b.gif"), { + keyword: "@충동적 구매@", + url: "https://cdn.test/b.gif", + }); +}); + +test("URL도 첨부도 없으면 사용법 안내", () => { + assert.ok("error" in parseAdd(" 키워드만")); +}); + +test("URL이 아닌 마지막 토큰은 거부", () => { + assert.ok("error" in parseAdd("키워드 그냥텍스트")); +}); + +test("! 로 시작하는 키워드는 거부", () => { + assert.ok("error" in parseAdd("!밥 https://x.test/a.gif")); +}); + +test("100자 초과 키워드는 거부", () => { + assert.ok("error" in parseAdd(`${"가".repeat(101)} https://x.test/a.gif`)); +}); + +test("http 주소는 거부 (https만 허용)", () => { + assert.ok("error" in parseAdd("키워드 http://x.test/a.gif")); +}); + +test("내부망 IP 차단", () => { + for (const ip of [ + "127.0.0.1", + "10.0.0.5", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + "0.0.0.0", + "::1", + "fd00::1", + "fe80::1", + "::ffff:127.0.0.1", + ]) { + assert.equal(isBlockedIp(ip), true, ip); + } +}); + +test("공인 IP는 통과", () => { + for (const ip of ["1.1.1.1", "162.159.130.234", "172.32.0.1", "192.169.0.1", "2606:4700::1111"]) { + assert.equal(isBlockedIp(ip), false, ip); + } +}); + +test("IPv4-mapped IPv6 16진 표기도 차단", () => { + assert.equal(isBlockedIp("::ffff:7f00:1"), true); // 127.0.0.1 + assert.equal(isBlockedIp("::ffff:a9fe:a9fe"), true); // 169.254.169.254 + assert.equal(isBlockedIp("::ffff:0a00:0001"), true); // 10.0.0.1 +}); + +test("NAT64/벤치마크 대역 차단", () => { + assert.equal(isBlockedIp("64:ff9b::7f00:1"), true); + assert.equal(isBlockedIp("198.18.0.1"), true); + assert.equal(isBlockedIp("192.0.0.1"), true); +}); + +function streamOf(totalBytes: number, chunk = 64 * 1024): Response { + let sent = 0; + return new Response( + new ReadableStream({ + pull(controller) { + if (sent >= totalBytes) return controller.close(); + const size = Math.min(chunk, totalBytes - sent); + sent += size; + controller.enqueue(new Uint8Array(size)); + }, + }), + ); +} + +test("12MB 넘는 응답은 다 받지 않고 끊는다", async () => { + assert.equal(await readCapped(streamOf(12 * 1024 * 1024 + 1)), null); +}); + +test("정확히 12MB인 응답은 허용한다", async () => { + const data = await readCapped(streamOf(12 * 1024 * 1024)); + assert.equal(data?.byteLength, 12 * 1024 * 1024); +}); + +test("한도 이내 응답은 그대로 반환", async () => { + const data = await readCapped(streamOf(1024)); + assert.equal(data?.byteLength, 1024); +}); + +test("문장 중간에 있어도 잡는다", () => { + const set = new Set(["@충동적 구매@"]); + assert.deepEqual(findKeywords(set, "이거 @충동적 구매@ ㅋㅋ"), ["@충동적 구매@"]); + assert.deepEqual(findKeywords(set, "@충동적 구매@"), ["@충동적 구매@"]); + assert.deepEqual(findKeywords(set, "그냥 잡담"), []); +}); + +test("겹치는 키워드도 모두 반환한다", () => { + const set = new Set(["구매", "@충동적 구매@"]); + assert.deepEqual(findKeywords(set, "이거 @충동적 구매@ 했다"), ["구매", "@충동적 구매@"]); + assert.deepEqual(findKeywords(set, "구매했음"), ["구매"]); +}); + +test("한 문장의 서로 다른 키워드는 각각 한 번씩 반환한다", () => { + assert.deepEqual(findKeywords(new Set(["안녕", "잘가"]), "안녕 잘가 안녕"), ["안녕", "잘가"]); +}); + +test("등록된 키워드가 없으면 빈 목록을 반환한다", () => { + assert.deepEqual(findKeywords(undefined, "안녕"), []); +}); + +test("1글자 키워드는 등록 거부", () => { + assert.ok("error" in parseAdd("ㅋ https://x.test/a.gif")); +}); + +test("한 메시지에서는 등록 순서대로 최대 4개 키워드만 반환한다", () => { + const set = new Set(["하나", "둘둘", "셋셋", "넷넷", "다섯"]); + assert.deepEqual(findKeywords(set, "다섯 넷넷 셋셋 둘둘 하나"), ["하나", "둘둘", "셋셋", "넷넷"]); +}); diff --git a/index.ts b/index.ts index fa73215..f12c633 100644 --- a/index.ts +++ b/index.ts @@ -5,114 +5,146 @@ import { GatewayIntentBits, EmbedBuilder, Message, - ButtonInteraction, + MessageFlags, + Options, ChatInputCommandInteraction, } from "discord.js"; import { handleCasino, handleButtonInteraction, handleCasinoSlash } from "./casino/handler.js"; -import { handleMeal, handleMealSlash } from "./meal/handler.js"; +import { handleMeal, handleMealSlash, initMealCacheWarmer } from "./meal/handler.js"; import { handleScheduler, initScheduler, handleSchedulerSlash } from "./scheduler/handler.js"; import { handleTimetable, handleTimetableSlash } from "./timetable/handler.js"; import { init as initDb } from "./db.js"; import { handleRandom } from "./random/handler.js"; +import { handleGif, handleGifSlash, initGifCache } from "./gif/handler.js"; import { handleMusic, handleMusicSlash } from "./music/handler.js"; import { handleStatus, handleStatusSlash } from "./status/handler.js"; import { handleAcademic, handleAcademicSlash } from "./academic/handler.js"; import { handleWeather, handleWeatherSlash } from "./weather/handler.js"; +import { initWeatherCacheWarmer } from "./weather/cache.js"; import { sendBotStatus } from "./webhook.js"; import { registerCommands } from "./commands.js"; -async function handleHelp(message: Message): Promise { - if (message.content.trim() !== "!명령어") return false; - const embed = buildHelpEmbed(); - message.reply({ embeds: [embed] }); - return true; -} - -function buildHelpEmbed(): EmbedBuilder { - return new EmbedBuilder() - .setColor(0x5865f2) - .setTitle("📖 명령어 목록") - .addFields( - { - name: "🍽️ 급식", - value: [ - "`!밥` / `!급식` — 현재 시간대 급식", - "`!아침` / `!점심` / `!저녁` — 오늘 해당 끼니", - "`!오늘아침` / `!오늘점심` / `!오늘저녁`", - "`!내일아침` / `!내일점심` / `!내일저녁`", - ].join("\n"), - }, - { - name: "📅 시간표", - value: [ - "`!시간표` / `!ㅅㄱㅍ` — 내 반 시간표 (역할 필요)", - "`!시간표 N-M` / `!ㅅㄱㅍ N-M` — N학년 M반 시간표 (역할 불필요)", - ].join("\n"), - }, - { - name: "🗓️ 학사일정", - value: ["`!학사일정` — 이번 달 학사일정", "`!학사일정 N월` — N월 학사일정"].join("\n"), - }, - { - name: "💰 경제", - value: [ - "`!출석` — 매일 10,000원 지급", - "`!일` / `!노동` — 10,000~30,000원 (1분 쿨다운)", - "`!잔액` — 내 잔액 확인", - "`!지원금` — 잔액 0원일 때 100,000원 (1시간 쿨다운)", - "`!송금 @멘션 금액` — 다른 유저에게 송금", - "`!랭킹` — 서버 잔액 TOP 10", - ].join("\n"), - }, - { - name: "🎰 도박", - value: [ - "`!코인 금액` — 코인플립 (앞/뒷면)", - "`!블랙잭 금액` — 블랙잭", - "`!바카라 금액` — 바카라 (플레이어/뱅커/타이)", - "`!룰렛 금액` — 룰렛 (홀/짝/검/빨)", - "※ 금액 대신 `올인` / `반` 사용 가능", - ].join("\n"), - }, - { - name: "🎧 음악", - value: [ - "`!play [제목]` — 유튜브에서 검색 후 재생", - "`!play url [URL]` — 유튜브 URL로 바로 재생", - "`!스킵` — 현재 곡 건너뜀", - "`!정지` — 재생 중지 및 음성채널 퇴장", - "`!일시정지` / `!재개` — 일시정지 / 재개", - "`!큐` / `!대기열` — 재생 대기열 확인", - "`!노추` / `!오노추` — Spotify 랜덤 노래 추천", - "`!노추 [장르]` — 장르별 노래 추천 (케이팝, 팝, 힙합 등)", - "`!가수 [키워드]` — Spotify 노래/아티스트 검색", - ].join("\n"), - }, - { - name: "🌤️ 날씨", - value: "`!날씨` / `!ㄴㅆ` — 현재 날씨 및 미세먼지", - }, - { - name: "🤖 봇 상태", - value: "`!상태` — 봇 업타임, 핑, 메모리, API 상태 확인", - }, - { - name: "🔔 알림", - value: [ - "`!보내기` — 정기 알림 설정", - "`!알림목록` — 등록된 알림 확인", - "`!알림삭제 번호` — 알림 삭제", - "`!알림삭제전체` — 이 서버 알림 전체 삭제", - "`!보내기취소` — 설정 중 취소", - ].join("\n"), - }, - ); -} +// 매번 새로 만들 필요 없는 고정 내용이라 한 번만 만든다 +const HELP_EMBED = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle("📖 명령어 목록") + .addFields( + { + name: "🍽️ 급식", + value: [ + "`!밥` / `!급식` — 현재 시간대 급식", + "`!아침` / `!점심` / `!저녁` — 오늘 해당 끼니", + "`!오늘아침` / `!오늘점심` / `!오늘저녁`", + "`!내일아침` / `!내일점심` / `!내일저녁`", + "`!모레아침` / `!모레점심` / `!모레저녁`", + "`/급식 끼니 특정날짜:10/5` — 특정 날짜 급식", + "`!밥 on` / `!밥 off` / `/밥 설정:on|off` — 급식 기능 켜기/끄기 (관리자)", + ].join("\n"), + }, + { + name: "📅 시간표", + value: [ + "`!시간표` / `!ㅅㄱㅍ` — 내 반 시간표 (역할 필요)", + "`!시간표 N-M` / `!ㅅㄱㅍ N-M` — N학년 M반 시간표 (역할 불필요)", + ].join("\n"), + }, + { + name: "🗓️ 학사일정", + value: ["`!학사일정` — 이번 달 학사일정", "`!학사일정 N월` — N월 학사일정"].join("\n"), + }, + { + name: "💰 경제", + value: [ + "`!출석` — 매일 10,000원 지급", + "`!일` / `!노동` — 10,000~30,000원 (1분 쿨다운)", + "`!잔액` — 내 잔액 확인", + "`!지원금` — 잔액 0원일 때 100,000원 (1시간 쿨다운)", + "`!송금 @멘션 금액` — 다른 유저에게 송금", + "`!랭킹` — 서버 잔액 TOP 10", + ].join("\n"), + }, + { + name: "🎰 도박", + value: [ + "`!코인 금액` — 코인플립 (앞/뒷면)", + "`!블랙잭 금액` — 블랙잭", + "`!바카라 금액` — 바카라 (플레이어/뱅커/타이)", + "`!룰렛 금액` — 룰렛 (홀/짝/검/빨)", + "※ 금액 대신 `올인` / `반` 사용 가능", + ].join("\n"), + }, + { + name: "🎧 음악", + value: [ + "`!play [제목]` — 유튜브에서 검색 후 재생", + "`!play url [URL]` — 유튜브 URL로 바로 재생", + "`!스킵` — 현재 곡 건너뜀", + "`!정지` — 재생 중지 및 음성채널 퇴장", + "`!일시정지` / `!재개` — 일시정지 / 재개", + "`!큐` / `!대기열` — 재생 대기열 확인", + "`!노추` / `!오노추` — Spotify 랜덤 노래 추천", + "`!노추 [장르]` — 장르별 노래 추천 (케이팝, 팝, 힙합 등)", + "`!가수 [키워드]` — Spotify 노래/아티스트 검색", + ].join("\n"), + }, + { + name: "🌤️ 날씨", + value: "`!날씨` / `!ㄴㅆ` — 현재 날씨 및 미세먼지", + }, + { + name: "🤖 봇 상태", + value: "`!상태` — 봇 업타임, 핑, 메모리, API 상태 확인", + }, + { + name: "🖼️ GIF", + value: [ + "`!gif등록 키워드 URL` — 키워드에 GIF 등록 (GIF 파일 첨부도 가능)", + "`!gif목록` — 등록된 키워드 확인", + "`!gif삭제 키워드` — 등록 해제", + "※ 메시지에 등록한 키워드가 들어 있으면 봇이 GIF를 보냅니다", + ].join("\n"), + }, + { + name: "🔔 알림", + value: [ + "`!보내기` — 정기 알림 설정", + "`!알림목록` — 등록된 알림 확인", + "`!알림삭제 번호` — 알림 삭제", + "`!알림삭제전체` — 이 서버 알림 전체 삭제", + "`!보내기취소` — 설정 중 취소", + ].join("\n"), + }, + ); -async function handleHelpSlash(interaction: ChatInputCommandInteraction): Promise { - const embed = buildHelpEmbed(); - await interaction.reply({ embeds: [embed] }); -} +const GUILD_ONLY = new Set([ + "출석", + "일", + "잔액", + "지원금", + "랭킹", + "송금", + "도박", + "코인", + "블랙잭", + "바카라", + "룰렛", + "시간표", + "보내기", + "알림목록", + "알림삭제", + "알림삭제전체", + "play", + "스킵", + "정지", + "일시정지", + "재개", + "큐", + "노추", + "가수", + "gif등록", + "gif목록", + "gif삭제", +]); const token = process.env.DISCORD_TOKEN; const client = new Client({ @@ -123,23 +155,52 @@ const client = new Client({ GatewayIntentBits.MessageContent, GatewayIntentBits.GuildVoiceStates, ], + // 봇은 지난 메시지/리액션/스티커 등을 캐시에서 다시 꺼내 쓰지 않는다. + // 기본값(채널당 메시지 200개 등)으로 두면 서버가 많을수록 메모리만 계속 먹는다. + makeCache: Options.cacheWithLimits({ + ...Options.DefaultMakeCacheSettings, + MessageManager: 25, + ReactionManager: 0, + ReactionUserManager: 0, + GuildStickerManager: 0, + GuildScheduledEventManager: 0, + StageInstanceManager: 0, + PresenceManager: 0, + GuildInviteManager: 0, + }), + sweepers: { + ...Options.DefaultSweeperSettings, + messages: { interval: 60 * 60, lifetime: 30 * 60 }, + }, }); client.once(Events.ClientReady, async (readyClient) => { console.log(`Ready! Logged in as ${readyClient.user.tag}`); try { await initDb(); - initScheduler(readyClient); - await sendBotStatus("online"); - const clientId = readyClient.user.id; - const guildId = process.env.GUILD_ID; - await registerCommands(clientId, guildId).catch(console.error); } catch (e) { console.error("DB 연결 실패:", (e as Error).message); + return; + } + + initMealCacheWarmer(); + initWeatherCacheWarmer(); + // 서로 의존하지 않는 초기화는 동시에 진행해서 부팅을 빠르게 한다 + const results = await Promise.allSettled([ + initGifCache(), + initScheduler(readyClient), + sendBotStatus("online"), + registerCommands(readyClient.user.id, process.env.GUILD_ID), + ]); + for (const r of results) { + if (r.status === "rejected") console.error("[Init]", r.reason); } }); +let shuttingDown = false; async function shutdown(): Promise { + if (shuttingDown) return; + shuttingDown = true; await sendBotStatus("offline"); process.exit(0); } @@ -147,7 +208,24 @@ async function shutdown(): Promise { process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); -const GUILD_ONLY = new Set([ +// 핸들러 곳곳에서 await 없이 보내는 답장이 실패(권한 없음, 메시지 삭제됨 등)하면 unhandled rejection이 되고, +// Node 기본 설정에서는 그 한 번으로 봇 프로세스 전체가 죽는다. 로그만 남기고 계속 돌게 한다. +process.on("unhandledRejection", (reason) => { + console.error("[unhandledRejection]", reason); +}); + +type SlashHandler = (i: ChatInputCommandInteraction) => Promise; + +const SLASH_HANDLERS: Record = { + 밥: handleMealSlash, + 급식: handleMealSlash, + 시간표: handleTimetableSlash, + 학사일정: handleAcademicSlash, + 날씨: handleWeatherSlash, + 상태: (i) => handleStatusSlash(i, client), + 명령어: async (i) => void (await i.reply({ embeds: [HELP_EMBED] })), +}; +for (const name of [ "출석", "일", "잔액", @@ -159,102 +237,75 @@ const GUILD_ONLY = new Set([ "블랙잭", "바카라", "룰렛", - "시간표", - "보내기", - "알림목록", - "알림삭제", - "알림삭제전체", - "play", - "스킵", - "정지", - "일시정지", - "재개", - "큐", - "노추", - "가수", -]); +]) + SLASH_HANDLERS[name] = handleCasinoSlash; +for (const name of ["play", "스킵", "정지", "일시정지", "재개", "큐", "노추", "가수"]) + SLASH_HANDLERS[name] = handleMusicSlash; +for (const name of ["보내기", "알림목록", "알림삭제", "알림삭제전체"]) + SLASH_HANDLERS[name] = handleSchedulerSlash; +for (const name of ["gif등록", "gif목록", "gif삭제"]) SLASH_HANDLERS[name] = handleGifSlash; client.on(Events.InteractionCreate, async (interaction) => { - if (interaction.isButton()) { - await handleButtonInteraction(interaction as ButtonInteraction); - return; - } - if (!interaction.isChatInputCommand()) return; - - if (!interaction.guildId && GUILD_ONLY.has(interaction.commandName)) { - await interaction.reply({ - content: "❌ 이 명령어는 서버에서만 사용할 수 있습니다.", - ephemeral: true, - }); - return; - } + try { + if (interaction.isButton()) { + await handleButtonInteraction(interaction); + return; + } + if (!interaction.isChatInputCommand()) return; - const i = interaction as ChatInputCommandInteraction; + if (!interaction.guildId && GUILD_ONLY.has(interaction.commandName)) { + await interaction.reply({ + content: "❌ 이 명령어는 서버에서만 사용할 수 있습니다.", + flags: MessageFlags.Ephemeral, + }); + return; + } - switch (i.commandName) { - case "밥": - case "급식": - await handleMealSlash(i); - break; - case "시간표": - await handleTimetableSlash(i); - break; - case "학사일정": - await handleAcademicSlash(i); - break; - case "날씨": - await handleWeatherSlash(i); - break; - case "상태": - await handleStatusSlash(i, client); - break; - case "출석": - case "일": - case "잔액": - case "지원금": - case "랭킹": - case "송금": - case "도박": - case "코인": - case "블랙잭": - case "바카라": - case "룰렛": - await handleCasinoSlash(i); - break; - case "play": - case "스킵": - case "정지": - case "일시정지": - case "재개": - case "큐": - case "노추": - case "가수": - await handleMusicSlash(i); - break; - case "보내기": - case "알림목록": - case "알림삭제": - case "알림삭제전체": - await handleSchedulerSlash(i); - break; - case "명령어": - await handleHelpSlash(i); - break; + await SLASH_HANDLERS[interaction.commandName]?.(interaction); + } catch (err) { + console.error(`[Interaction] ${interaction.isCommand() ? interaction.commandName : ""}`, err); + if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) { + await interaction + .reply({ content: "❌ 처리 중 오류가 발생했습니다.", flags: MessageFlags.Ephemeral }) + .catch(() => {}); + } } }); +type MessageHandler = (message: Message) => Promise; + +// "!"로 시작하는 명령 메시지용. 순서가 곧 우선순위다. +const COMMAND_HANDLERS: MessageHandler[] = [ + async (m) => { + if (m.content.trim() !== "!명령어") return false; + await m.reply({ embeds: [HELP_EMBED] }); + return true; + }, + (m) => handleStatus(m, client), + handleCasino, + handleRandom, + handleMusic, + handleScheduler, + handleTimetable, + handleAcademic, + handleWeather, + handleGif, + handleMeal, +]; + +// 일반 채팅 메시지는 명령 파서를 전부 거칠 필요 없이, 알림 설정 마법사 입력과 GIF 키워드만 보면 된다 +const CHAT_HANDLERS: MessageHandler[] = [handleScheduler, handleGif]; + client.on(Events.MessageCreate, async (message) => { if (message.author.bot) return; - if (await handleHelp(message)) return; - if (await handleStatus(message, client)) return; - if (await handleCasino(message)) return; - if (await handleRandom(message)) return; - if (await handleMusic(message)) return; - if (await handleScheduler(message)) return; - if (await handleTimetable(message)) return; - if (await handleAcademic(message)) return; - if (await handleWeather(message)) return; - await handleMeal(message); + const handlers = message.content.trimStart().startsWith("!") ? COMMAND_HANDLERS : CHAT_HANDLERS; + try { + for (const handle of handlers) { + if (await handle(message)) return; + } + } catch (err) { + console.error(`[Message] ${message.content.slice(0, 50)}`, err); + } }); client.login(token); 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 335751d..e1ddf53 100644 --- a/meal/handler.ts +++ b/meal/handler.ts @@ -1,198 +1,264 @@ import { EmbedBuilder, Message, ChatInputCommandInteraction } from "discord.js"; -import https from "https"; import { kstNow, toNeisDateStr, fetchWithRetry, + fetchNeis, + onEveryKstMinute, getFallbackMeal, - NEIS_KEY, - ATPT_CODE, - SCHOOL_CODE, } from "../utils.js"; -import { Ctx, ctxFromMessage, ctxFromInteraction } from "../ctx.js"; +import { getMealEnabled, setMealEnabled } from "../db.js"; +import { Ctx, ctxFromMessage, ctxFromInteraction, handleAdminToggle } from "../ctx.js"; +import { TtlCache } from "../cache.js"; + +type MealDay = "auto" | "today" | "tomorrow" | "dayAfter"; + +// 텍스트 명령 → (끼니, 날짜). "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" }, + "!모레아침": { type: 1, day: "dayAfter" }, + "!모레점심": { type: 2, day: "dayAfter" }, + "!모레저녁": { type: 3, day: "dayAfter" }, +}; 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 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: "내일" }; + 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: "오늘" }; 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; +// "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); } -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(8000, () => req.destroy(new Error("NEIS API 요청 시간 초과"))); - req.on("error", reject); +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 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 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 { + warmMealCache().catch(() => {}); + onEveryKstMinute((kst) => { + if (kst.getUTCMinutes() % 30 === 0) return warmMealCache(); + }); +} + +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(); } -async function executeMeal( - ctx: Ctx, - mealType: number, - dateStr: string, - dayLabel: string, -): Promise { - try { - let result: MealResult | null = null; - let isFallback = false; +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 fetchWithRetry(() => fetchMeal(dateStr, mealType)); + result = await loadMeal(dateStr, type); } catch (err) { + apiError = err as Error; + result = null; console.warn( - `[NEIS] 급식 API 호출 실패, fallback 데이터 사용을 시도합니다: ${(err as Error).message}`, + `[NEIS] 급식 API 호출 실패, fallback 데이터 사용을 시도합니다: ${apiError.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]} 급식 정보가 없습니다.`); + } + + let isFallback = false; + if (!result) { + const fb = getFallbackMeal(dateStr, type); + if (fb) { + result = fb; + isFallback = true; } - } 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] }); } + + if (!result) { + // API 자체가 실패했는데 "급식 없음"이라고 하면 헷갈리므로 오류를 그대로 보여준다 + await ctx.reply( + apiError + ? { embeds: [errorEmbed(apiError)] } + : `😢 ${dayLabel} ${MEAL_LABELS[type]} 급식 정보가 없습니다.`, + ); + return; + } + + 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 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(content)) { - ({ type: mealType, dayLabel, dateStr } = getMealByTime()); - } else if (["!오늘아침", "!아침"].includes(content)) { - mealType = 1; - dayLabel = "오늘"; - dateStr = todayStr; - } else if (["!오늘점심", "!점심"].includes(content)) { - mealType = 2; - dayLabel = "오늘"; - dateStr = todayStr; - } else if (["!오늘저녁", "!저녁"].includes(content)) { - mealType = 3; - dayLabel = "오늘"; - dateStr = todayStr; - } else if (content === "!내일아침") { - mealType = 1; - dayLabel = "내일"; - dateStr = tomorrowStr; - } else if (content === "!내일점심") { - mealType = 2; - dayLabel = "내일"; - dateStr = tomorrowStr; - } else if (content === "!내일저녁") { - mealType = 3; - dayLabel = "내일"; - dateStr = tomorrowStr; - } else return false; - - await executeMeal(ctxFromMessage(message), mealType, dateStr, dayLabel); + const [cmd, arg] = message.content.trim().split(/\s+/); + const setting = arg?.toLowerCase(); + + if (cmd === "!밥" && (setting === "on" || setting === "off")) { + await handleAdminToggle(ctxFromMessage(message), setting === "on", "급식", setMealEnabled); + return true; + } + + const spec = MEAL_CMDS[cmd]; + if (!spec) return false; + + // 급식 기능이 꺼진 서버에서는 아예 반응하지 않는다 + if (message.guild && !(await getMealEnabled(message.guild.id))) return true; + + await executeMeal(ctxFromMessage(message), resolveMeal(spec.type, spec.day)); return true; } export async function handleMealSlash(interaction: ChatInputCommandInteraction): Promise { - 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 (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; + 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; + } + + if (interaction.guildId && !(await getMealEnabled(interaction.guildId))) { + await ctx.replyPrivate("🔒 현재 서버에서 급식 기능이 비활성화되어 있습니다."); + return; + } + + if (interaction.commandName === "밥") { + await executeMeal(ctx, resolveMeal(0, "auto")); + return; + } + + // commandName === "급식" + const mealMap: Record = { 아침: 1, 점심: 2, 저녁: 3 }; + const type = mealMap[interaction.options.getString("끼니", true)]; + + // 특정날짜가 있으면 날짜 선택보다 우선한다 + 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; } - await executeMeal(ctxFromInteraction(interaction), mealType, dateStr, dayLabel); + const dayMap: Record = { 오늘: "today", 내일: "tomorrow", 모레: "dayAfter" }; + const day = dayMap[interaction.options.getString("날짜") ?? "오늘"] ?? "today"; + await executeMeal(ctx, resolveMeal(type, day)); } diff --git a/music/handler.ts b/music/handler.ts index ce540f4..0bc7b87 100644 --- a/music/handler.ts +++ b/music/handler.ts @@ -1,5 +1,5 @@ import { AudioPlayerStatus } from "@discordjs/voice"; -import { EmbedBuilder, GuildMember, Message, ChatInputCommandInteraction } from "discord.js"; +import { EmbedBuilder, Message, ChatInputCommandInteraction } from "discord.js"; import play from "play-dl"; import { searchTracks, SpotifyTrack } from "./spotify.js"; import { @@ -12,6 +12,7 @@ import { stop, QueueItem, } from "./player.js"; +import { Ctx, ctxFromMessage, ctxFromInteraction } from "../ctx.js"; // ─── Spotify 추천 (기존 기능 유지) ───────────────────────────────────────── @@ -115,28 +116,230 @@ const GENRE_ARTISTS: Record = { const GENRE_LIST = Object.keys(GENRE_ARTISTS); const RECOMMEND_CMDS = ["!노추", "!오노추"] as const; -// ─── 음성채널 검증 헬퍼 ───────────────────────────────────────────────────── +const pick = (arr: T[]): T => arr[Math.floor(Math.random() * arr.length)]; -function getVoiceChannel(message: Message) { - if (!message.guild) return null; - const member = message.member as GuildMember | null; - return member?.voice?.channel ?? null; +// ─── Spotify 추천 / 검색 ──────────────────────────────────────────────────── + +async function executeRecommend(ctx: Ctx, genreInput: string | null): Promise { + const genreKey = genreInput ?? pick(GENRE_LIST); + await ctx.defer(); + try { + const artist = pick(GENRE_ARTISTS[genreKey]); + const data = await searchTracks(`artist:"${artist}"`, 10, 0); + const tracks = data.tracks?.items; + if (!tracks || tracks.length === 0) { + await ctx.reply("😢 추천 곡을 찾지 못했습니다. 다시 시도해보세요."); + return; + } + const label = genreInput ? genreKey : `${genreKey} (랜덤)`; + await ctx.reply({ embeds: [buildTrackEmbed(pick(tracks), `🎧 ${label} 추천 노래`, 0x1db954)] }); + } catch (err) { + console.error(err); + await ctx.reply("❌ Spotify API 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + } +} + +async function executeArtistSearch(ctx: Ctx, query: string): Promise { + await ctx.defer(); + try { + const data = await searchTracks(query, 10); + const tracks = data.tracks?.items; + if (!tracks || tracks.length === 0) { + await ctx.reply(`😢 **${query}** 검색 결과가 없습니다.`); + return; + } + const unique = [...new Map(tracks.map((t) => [t.id, t])).values()]; + await ctx.reply({ + embeds: [buildTrackEmbed(pick(unique), `🔍 "${query}" 검색 결과`, 0x5865f2)], + }); + } catch (err) { + console.error(err); + await ctx.reply("❌ Spotify API 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + } +} + +// ─── 유튜브 재생 ──────────────────────────────────────────────────────────── + +const PLAY_USAGE_EMBED = new EmbedBuilder() + .setColor(0xff0000) + .setTitle("🎵 음악 재생") + .setDescription("음성 채널에 입장한 후 아래 명령어를 사용하세요.") + .addFields( + { name: "🔍 제목으로 검색", value: "`!play [제목]`\n예: `!play 아이유 Celebrity`" }, + { + name: "🔗 URL로 재생", + value: "`!play url [유튜브 URL]`\n예: `!play url https://youtu.be/...`", + }, + { name: "⚙️ 기타 명령어", value: "`!스킵` `!정지` `!일시정지` `!재개` `!큐`" }, + ); + +// URL이면 영상 정보를, 아니면 검색 결과 첫 번째 영상을 찾는다. 실패하면 사용자에게 보낼 문구. +async function resolveTrack(arg: string, requestedBy: string): Promise { + if (play.yt_validate(arg) === "video") { + try { + const d = (await play.video_info(arg)).video_details; + return { + title: d.title ?? "알 수 없음", + url: d.url ?? arg, + requestedBy, + duration: d.durationRaw ?? "?:??", + thumbnail: d.thumbnails?.[0]?.url, + }; + } catch (err) { + console.error("[Music] video_info 오류:", err); + return "❌ 영상 정보를 불러올 수 없습니다. URL을 확인해주세요."; + } + } + + try { + const results = await play.search(arg, { source: { youtube: "video" }, limit: 1 }); + const video = results[0]; + if (!video) return `😢 **${arg}** 검색 결과가 없습니다.`; + if (!video.url) { + console.error("[Music] 검색 결과 URL 없음:", video); + return `😢 **${arg}** 에 대한 재생 가능한 영상을 찾지 못했습니다.`; + } + return { + title: video.title ?? "알 수 없음", + url: video.url, + requestedBy, + duration: video.durationRaw ?? "?:??", + thumbnail: video.thumbnails?.[0]?.url, + }; + } catch (err) { + console.error("[Music] 검색 오류:", err); + return "❌ 검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."; + } +} + +async function executePlay(ctx: Ctx, arg: string): Promise { + if (!ctx.guild || !ctx.channel) return; + + const voiceChannel = ctx.voiceChannel; + if (!voiceChannel) { + await ctx.reply("❌ 음성 채널에 먼저 입장해주세요!"); + return; + } + const botMember = ctx.guild.members.me; + const perms = botMember ? voiceChannel.permissionsFor(botMember) : null; + if (!perms?.has("Connect") || !perms?.has("Speak")) { + await ctx.reply("❌ 해당 음성 채널에 접근 권한이 없습니다."); + return; + } + if (!arg) { + await ctx.reply({ embeds: [PLAY_USAGE_EMBED] }); + return; + } + + // 예전 텍스트 명령은 "검색 중..." 메시지를 보냈다가 지웠는데(API 2번), 이제 입력 중 표시로 대신한다 + await ctx.defer(); + const item = await resolveTrack(arg, ctx.username); + if (typeof item === "string") { + await ctx.reply(item); + return; + } + + try { + const result = await addToQueue(ctx.guild.id, voiceChannel, ctx.channel, item); + if (result === "queued") { + const { queue } = getQueue(ctx.guild.id); + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle("📋 대기열에 추가됨") + .setDescription(`**[${item.title}](${item.url})**`) + .addFields( + { name: "⏱️ 길이", value: item.duration, inline: true }, + { name: "📍 대기 순서", value: `${queue.length}번째`, inline: true }, + ); + if (item.thumbnail) embed.setThumbnail(item.thumbnail); + await ctx.reply({ embeds: [embed] }); + } else if (ctx.isInteraction) { + // 텍스트 명령은 플레이어가 "지금 재생 중" 임베드를 보내므로 따로 답하지 않는다 + await ctx.reply({ content: `▶️ 재생을 시작합니다: **${item.title}**` }); + } + } catch (err) { + console.error("[Music] addToQueue 오류:", err); + await ctx.reply(`❌ 오류가 발생했습니다: ${(err as Error).message}`); + } +} + +function buildQueueEmbed(guildId: string): EmbedBuilder | null { + const { current, queue } = getQueue(guildId); + if (!current && queue.length === 0) return null; + + const embed = new EmbedBuilder().setColor(0xff0000).setTitle("🎵 재생 대기열"); + if (current) { + const statusIcon = getPlayerStatus(guildId) === AudioPlayerStatus.Paused ? "⏸️" : "▶️"; + embed.addFields({ + name: `${statusIcon} 지금 재생 중`, + value: `[${current.title}](${current.url}) \`${current.duration}\` — ${current.requestedBy}`, + }); + } + if (queue.length > 0) { + const lines = queue + .slice(0, 10) + .map( + (item, i) => + `**${i + 1}.** [${item.title}](${item.url}) \`${item.duration}\` — ${item.requestedBy}`, + ) + .join("\n"); + embed.addFields({ name: `📋 대기열 (${queue.length}곡)`, value: lines }); + if (queue.length > 10) embed.setFooter({ text: `외 ${queue.length - 10}곡 더...` }); + } + return embed; +} + +// 스킵/정지/일시정지/재개/큐 — prefix는 재개 안내 문구에 쓴다 ("!" 또는 "/") +function executeControl(ctx: Ctx, cmd: string, prefix: string): Promise { + const guildId = ctx.guildId!; + switch (cmd) { + case "스킵": { + const skipped = skip(guildId); + return ctx.reply( + skipped ? `⏭️ **${skipped.title}** 건너뜁니다.` : "📭 현재 재생 중인 곡이 없습니다.", + ); + } + case "정지": + return ctx.reply( + stop(guildId) + ? "⏹️ 재생을 정지하고 음성 채널에서 나갑니다." + : "📭 현재 재생 중인 곡이 없습니다.", + ); + case "일시정지": + return ctx.reply( + pause(guildId) + ? `⏸️ 일시정지했습니다. \`${prefix}재개\` 로 이어서 재생할 수 있습니다.` + : "📭 일시정지할 수 있는 곡이 없습니다.", + ); + case "재개": + return ctx.reply( + resume(guildId) ? "▶️ 재생을 재개합니다." : "📭 재개할 수 있는 곡이 없습니다.", + ); + default: { + const embed = buildQueueEmbed(guildId); + return ctx.reply(embed ? { embeds: [embed] } : "📭 현재 재생 중인 곡이 없습니다."); + } + } } +const TEXT_CONTROLS: Record = { + "!스킵": "스킵", + "!정지": "정지", + "!일시정지": "일시정지", + "!재개": "재개", + "!큐": "큐", + "!대기열": "큐", +}; + // ─── 메인 핸들러 ──────────────────────────────────────────────────────────── export async function handleMusic(message: Message): Promise { const content = message.content.trim(); - // ── Spotify 추천 ────────────────────────────────────────────────────────── const recCmd = RECOMMEND_CMDS.find((cmd) => content === cmd || content.startsWith(cmd + " ")); if (recCmd) { const input = content.slice(recCmd.length).trim(); - const genreKey = - GENRE_LIST.find((g) => g === input) || - (input === "" ? GENRE_LIST[Math.floor(Math.random() * GENRE_LIST.length)] : null); - - if (input && !genreKey) { + if (input && !GENRE_LIST.includes(input)) { const embed = new EmbedBuilder() .setColor(0x1db954) .setTitle("🎧 노래 추천") @@ -147,269 +350,36 @@ export async function handleMusic(message: Message): Promise { message.reply({ embeds: [embed] }); return true; } - - try { - const artists = GENRE_ARTISTS[genreKey!]; - const artist = artists[Math.floor(Math.random() * artists.length)]; - const data = await searchTracks(`artist:"${artist}"`, 10, 0); - const tracks = data.tracks?.items; - if (!tracks || tracks.length === 0) { - message.reply("😢 추천 곡을 찾지 못했습니다. 다시 시도해보세요."); - return true; - } - const picked = tracks[Math.floor(Math.random() * tracks.length)]; - const label = input ? genreKey : `${genreKey} (랜덤)`; - const embed = buildTrackEmbed(picked, `🎧 ${label} 추천 노래`, 0x1db954); - message.reply({ embeds: [embed] }); - } catch (err) { - console.error(err); - message.reply("❌ Spotify API 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); - } + await executeRecommend(ctxFromMessage(message), input || null); return true; } - // ── !가수 검색 ──────────────────────────────────────────────────────────── if (content.startsWith("!가수 ")) { const query = content.slice("!가수 ".length).trim(); if (!query) { message.reply("❌ 검색어를 입력해주세요. 예: `!가수 아이유`"); return true; } - try { - const data = await searchTracks(query, 10); - const tracks = data.tracks?.items; - if (!tracks || tracks.length === 0) { - message.reply(`😢 **${query}** 검색 결과가 없습니다.`); - return true; - } - const unique = [...new Map(tracks.map((t) => [t.id, t])).values()]; - const picked = unique[Math.floor(Math.random() * unique.length)]; - const embed = buildTrackEmbed(picked, `🔍 "${query}" 검색 결과`, 0x5865f2); - message.reply({ embeds: [embed] }); - } catch (err) { - console.error(err); - message.reply("❌ Spotify API 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); - } + await executeArtistSearch(ctxFromMessage(message), query); return true; } - // ── !play 재생 ─────────────────────────────────────────────────────────── if (content === "!play" || content.startsWith("!play ")) { - if (!message.guild) return true; - - const voiceChannel = getVoiceChannel(message); - if (!voiceChannel) { - message.reply("❌ 음성 채널에 먼저 입장해주세요!"); - return true; - } - - const botMember = message.guild.members.me; - const perms = botMember ? voiceChannel.permissionsFor(botMember) : null; - if (!perms?.has("Connect") || !perms?.has("Speak")) { - message.reply("❌ 해당 음성 채널에 접근 권한이 없습니다."); - return true; - } - - const arg = content.slice("!play".length).trim(); - - if (!arg) { - const embed = new EmbedBuilder() - .setColor(0xff0000) - .setTitle("🎵 음악 재생") - .setDescription("음성 채널에 입장한 후 아래 명령어를 사용하세요.") - .addFields( - { - name: "🔍 제목으로 검색", - value: "`!play [제목]`\n예: `!play 아이유 Celebrity`", - }, - { - name: "🔗 URL로 재생", - value: "`!play url [유튜브 URL]`\n예: `!play url https://youtu.be/...`", - }, - { - name: "⚙️ 기타 명령어", - value: "`!스킵` `!정지` `!일시정지` `!재개` `!큐`", - }, - ); - message.reply({ embeds: [embed] }); - return true; - } - - let item: QueueItem; - - // URL 재생 + let arg = content.slice("!play".length).trim(); if (arg.toLowerCase().startsWith("url ")) { - const url = arg.slice(4).trim(); - if (play.yt_validate(url) !== "video") { + arg = arg.slice(4).trim(); + if (play.yt_validate(arg) !== "video") { message.reply("❌ 올바른 유튜브 영상 URL을 입력해주세요."); return true; } - - const loadingMsg = await message.reply("🔍 영상 정보를 불러오는 중..."); - try { - const info = await play.video_info(url); - const d = info.video_details; - item = { - title: d.title ?? "알 수 없음", - url: d.url ?? url, - requestedBy: message.author.username, - duration: d.durationRaw ?? "?:??", - thumbnail: d.thumbnails?.[0]?.url, - }; - await loadingMsg.delete().catch(() => {}); - } catch (err) { - console.error("[Music] video_info 오류:", err); - await loadingMsg - .edit("❌ 영상 정보를 불러올 수 없습니다. URL을 확인해주세요.") - .catch(() => {}); - return true; - } - } else { - // 제목 검색 - const loadingMsg = await message.reply(`🔍 **${arg}** 검색 중...`); - try { - const results = await play.search(arg, { source: { youtube: "video" }, limit: 1 }); - if (!results.length) { - await loadingMsg.edit(`😢 **${arg}** 검색 결과가 없습니다.`).catch(() => {}); - return true; - } - const video = results[0]; - if (!video.url) { - console.error("[Music] 검색 결과 URL 없음:", video); - await loadingMsg - .edit(`😢 **${arg}** 에 대한 재생 가능한 영상을 찾지 못했습니다.`) - .catch(() => {}); - return true; - } - item = { - title: video.title ?? "알 수 없음", - url: video.url, - requestedBy: message.author.username, - duration: video.durationRaw ?? "?:??", - thumbnail: video.thumbnails?.[0]?.url, - }; - await loadingMsg.delete().catch(() => {}); - } catch (err) { - console.error("[Music] 검색 오류:", err); - await loadingMsg - .edit("❌ 검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.") - .catch(() => {}); - return true; - } - } - - try { - const result = await addToQueue( - message.guild.id, - voiceChannel, - message.channel as import("discord.js").GuildTextBasedChannel, - item, - ); - if (result === "queued") { - const { queue } = getQueue(message.guild.id); - const embed = new EmbedBuilder() - .setColor(0x5865f2) - .setTitle("📋 대기열에 추가됨") - .setDescription(`**[${item.title}](${item.url})**`) - .addFields( - { name: "⏱️ 길이", value: item.duration, inline: true }, - { name: "📍 대기 순서", value: `${queue.length}번째`, inline: true }, - ); - if (item.thumbnail) embed.setThumbnail(item.thumbnail); - message.reply({ embeds: [embed] }); - } - } catch (err) { - console.error("[Music] addToQueue 오류:", err); - message.reply(`❌ 오류가 발생했습니다: ${(err as Error).message}`); } + await executePlay(ctxFromMessage(message), arg); return true; } - // ── !스킵 ───────────────────────────────────────────────────────────────── - if (content === "!스킵") { - if (!message.guild) return true; - const skipped = skip(message.guild.id); - if (!skipped) { - message.reply("📭 현재 재생 중인 곡이 없습니다."); - } else { - message.reply(`⏭️ **${skipped.title}** 건너뜁니다.`); - } - return true; - } - - // ── !정지 ───────────────────────────────────────────────────────────────── - if (content === "!정지") { - if (!message.guild) return true; - const stopped = stop(message.guild.id); - if (!stopped) { - message.reply("📭 현재 재생 중인 곡이 없습니다."); - } else { - message.reply("⏹️ 재생을 정지하고 음성 채널에서 나갑니다."); - } - return true; - } - - // ── !일시정지 ───────────────────────────────────────────────────────────── - if (content === "!일시정지") { - if (!message.guild) return true; - const paused = pause(message.guild.id); - if (!paused) { - message.reply("📭 일시정지할 수 있는 곡이 없습니다."); - } else { - message.reply("⏸️ 일시정지했습니다. `!재개` 로 이어서 재생할 수 있습니다."); - } - return true; - } - - // ── !재개 ───────────────────────────────────────────────────────────────── - if (content === "!재개") { - if (!message.guild) return true; - const resumed = resume(message.guild.id); - if (!resumed) { - message.reply("📭 재개할 수 있는 곡이 없습니다."); - } else { - message.reply("▶️ 재생을 재개합니다."); - } - return true; - } - - // ── !큐 / !대기열 ───────────────────────────────────────────────────────── - if (content === "!큐" || content === "!대기열") { - if (!message.guild) return true; - - const { current, queue } = getQueue(message.guild.id); - const status = getPlayerStatus(message.guild.id); - - if (!current && queue.length === 0) { - message.reply("📭 현재 재생 중인 곡이 없습니다."); - return true; - } - - const embed = new EmbedBuilder().setColor(0xff0000).setTitle("🎵 재생 대기열"); - - if (current) { - const statusIcon = status === AudioPlayerStatus.Paused ? "⏸️" : "▶️"; - embed.addFields({ - name: `${statusIcon} 지금 재생 중`, - value: `[${current.title}](${current.url}) \`${current.duration}\` — ${current.requestedBy}`, - }); - } - - if (queue.length > 0) { - const lines = queue - .slice(0, 10) - .map( - (item, i) => - `**${i + 1}.** [${item.title}](${item.url}) \`${item.duration}\` — ${item.requestedBy}`, - ) - .join("\n"); - embed.addFields({ name: `📋 대기열 (${queue.length}곡)`, value: lines }); - if (queue.length > 10) { - embed.setFooter({ text: `외 ${queue.length - 10}곡 더...` }); - } - } - - message.reply({ embeds: [embed] }); + const control = TEXT_CONTROLS[content]; + if (control) { + if (message.guild) await executeControl(ctxFromMessage(message), control, "!"); return true; } @@ -418,225 +388,23 @@ export async function handleMusic(message: Message): Promise { export async function handleMusicSlash(interaction: ChatInputCommandInteraction): Promise { const cmd = interaction.commandName; - const guildId = interaction.guildId; - const member = interaction.member instanceof GuildMember ? interaction.member : null; - const voiceChannel = member?.voice?.channel ?? null; + const ctx = ctxFromInteraction(interaction); - // ── /노추 ────────────────────────────────────────────────────────────────── if (cmd === "노추") { - const genreInput = interaction.options.getString("장르") ?? null; - const GENRE_ARTISTS_LOCAL = GENRE_ARTISTS; - const GENRE_LIST_LOCAL = GENRE_LIST; - const genreKey = - genreInput ?? GENRE_LIST_LOCAL[Math.floor(Math.random() * GENRE_LIST_LOCAL.length)]; - - await interaction.deferReply(); - try { - const artists = GENRE_ARTISTS_LOCAL[genreKey]; - const artist = artists[Math.floor(Math.random() * artists.length)]; - const data = await searchTracks(`artist:"${artist}"`, 10, 0); - const tracks = data.tracks?.items; - if (!tracks || tracks.length === 0) { - await interaction.editReply("😢 추천 곡을 찾지 못했습니다. 다시 시도해보세요."); - return; - } - const picked = tracks[Math.floor(Math.random() * tracks.length)]; - const label = genreInput ? genreKey : `${genreKey} (랜덤)`; - const embed = buildTrackEmbed(picked, `🎧 ${label} 추천 노래`, 0x1db954); - await interaction.editReply({ embeds: [embed] }); - } catch (err) { - console.error(err); - await interaction.editReply("❌ Spotify API 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); - } + await executeRecommend(ctx, interaction.options.getString("장르")); return; } - - // ── /가수 ────────────────────────────────────────────────────────────────── if (cmd === "가수") { - const query = interaction.options.getString("검색어", true); - await interaction.deferReply(); - try { - const data = await searchTracks(query, 10); - const tracks = data.tracks?.items; - if (!tracks || tracks.length === 0) { - await interaction.editReply(`😢 **${query}** 검색 결과가 없습니다.`); - return; - } - const unique = [...new Map(tracks.map((t) => [t.id, t])).values()]; - const picked = unique[Math.floor(Math.random() * unique.length)]; - const embed = buildTrackEmbed(picked, `🔍 "${query}" 검색 결과`, 0x5865f2); - await interaction.editReply({ embeds: [embed] }); - } catch (err) { - console.error(err); - await interaction.editReply("❌ Spotify API 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); - } + await executeArtistSearch(ctx, interaction.options.getString("검색어", true)); return; } - - // ── /play ────────────────────────────────────────────────────────────────── - if (cmd === "play") { - if (!guildId) return; - if (!voiceChannel) { - await interaction.reply("❌ 음성 채널에 먼저 입장해주세요!"); - return; - } - - const guild = interaction.guild!; - const botMember = guild.members.me; - const perms = botMember ? voiceChannel.permissionsFor(botMember) : null; - if (!perms?.has("Connect") || !perms?.has("Speak")) { - await interaction.reply("❌ 해당 음성 채널에 접근 권한이 없습니다."); - return; - } - - const arg = interaction.options.getString("검색어", true).trim(); - await interaction.deferReply(); - - let item: QueueItem; - - const isUrl = play.yt_validate(arg) === "video"; - if (isUrl) { - try { - const info = await play.video_info(arg); - const d = info.video_details; - item = { - title: d.title ?? "알 수 없음", - url: d.url ?? arg, - requestedBy: interaction.user.username, - duration: d.durationRaw ?? "?:??", - thumbnail: d.thumbnails?.[0]?.url, - }; - } catch (err) { - console.error("[Music] video_info 오류:", err); - await interaction.editReply("❌ 영상 정보를 불러올 수 없습니다. URL을 확인해주세요."); - return; - } - } else { - try { - const results = await play.search(arg, { source: { youtube: "video" }, limit: 1 }); - if (!results.length) { - await interaction.editReply(`😢 **${arg}** 검색 결과가 없습니다.`); - return; - } - const video = results[0]; - if (!video.url) { - console.error("[Music] 검색 결과 URL 없음:", video); - await interaction.editReply(`😢 **${arg}** 에 대한 재생 가능한 영상을 찾지 못했습니다.`); - return; - } - item = { - title: video.title ?? "알 수 없음", - url: video.url, - requestedBy: interaction.user.username, - duration: video.durationRaw ?? "?:??", - thumbnail: video.thumbnails?.[0]?.url, - }; - } catch (err) { - console.error("[Music] 검색 오류:", err); - await interaction.editReply("❌ 검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); - return; - } - } - - try { - const result = await addToQueue( - guildId, - voiceChannel, - interaction.channel as import("discord.js").GuildTextBasedChannel, - item, - ); - if (result === "queued") { - const { queue } = getQueue(guildId); - const embed = new EmbedBuilder() - .setColor(0x5865f2) - .setTitle("📋 대기열에 추가됨") - .setDescription(`**[${item.title}](${item.url})**`) - .addFields( - { name: "⏱️ 길이", value: item.duration, inline: true }, - { name: "📍 대기 순서", value: `${queue.length}번째`, inline: true }, - ); - if (item.thumbnail) embed.setThumbnail(item.thumbnail); - await interaction.editReply({ embeds: [embed] }); - } else { - await interaction.editReply({ content: `▶️ 재생을 시작합니다: **${item.title}**` }); - } - } catch (err) { - console.error("[Music] addToQueue 오류:", err); - await interaction.editReply(`❌ 오류가 발생했습니다: ${(err as Error).message}`); - } + if (!interaction.guildId) { + await ctx.replyPrivate("❌ 이 명령어는 서버에서만 사용할 수 있습니다."); return; } - - // ── /스킵 /정지 /일시정지 /재개 /큐 ───────────────────────────────────────── - if (!guildId) { - await interaction.reply({ - content: "❌ 이 명령어는 서버에서만 사용할 수 있습니다.", - ephemeral: true, - }); + if (cmd === "play") { + await executePlay(ctx, interaction.options.getString("검색어", true).trim()); return; } - - switch (cmd) { - case "스킵": { - const skipped = skip(guildId); - await interaction.reply( - skipped ? `⏭️ **${skipped.title}** 건너뜁니다.` : "📭 현재 재생 중인 곡이 없습니다.", - ); - break; - } - case "정지": { - const stopped = stop(guildId); - await interaction.reply( - stopped ? "⏹️ 재생을 정지하고 음성 채널에서 나갑니다." : "📭 현재 재생 중인 곡이 없습니다.", - ); - break; - } - case "일시정지": { - const paused = pause(guildId); - await interaction.reply( - paused - ? "⏸️ 일시정지했습니다. `/재개` 로 이어서 재생할 수 있습니다." - : "📭 일시정지할 수 있는 곡이 없습니다.", - ); - break; - } - case "재개": { - const resumed = resume(guildId); - await interaction.reply( - resumed ? "▶️ 재생을 재개합니다." : "📭 재개할 수 있는 곡이 없습니다.", - ); - break; - } - case "큐": { - const { current, queue } = getQueue(guildId); - const status = getPlayerStatus(guildId); - - if (!current && queue.length === 0) { - await interaction.reply("📭 현재 재생 중인 곡이 없습니다."); - return; - } - - const embed = new EmbedBuilder().setColor(0xff0000).setTitle("🎵 재생 대기열"); - if (current) { - const statusIcon = status === AudioPlayerStatus.Paused ? "⏸️" : "▶️"; - embed.addFields({ - name: `${statusIcon} 지금 재생 중`, - value: `[${current.title}](${current.url}) \`${current.duration}\` — ${current.requestedBy}`, - }); - } - if (queue.length > 0) { - const lines = queue - .slice(0, 10) - .map( - (item, i) => - `**${i + 1}.** [${item.title}](${item.url}) \`${item.duration}\` — ${item.requestedBy}`, - ) - .join("\n"); - embed.addFields({ name: `📋 대기열 (${queue.length}곡)`, value: lines }); - if (queue.length > 10) embed.setFooter({ text: `외 ${queue.length - 10}곡 더...` }); - } - await interaction.reply({ embeds: [embed] }); - break; - } - } + await executeControl(ctx, cmd, "/"); } diff --git a/music/player.ts b/music/player.ts index 09129b9..6114925 100644 --- a/music/player.ts +++ b/music/player.ts @@ -92,8 +92,9 @@ async function playNext(guildId: string): Promise { gp.ytdlProcess = subprocess; let stderr = ""; + // 에러 로그용으로 앞부분만 있으면 되는데, 곡 하나 내내 쌓이면 불필요하게 커진다 subprocess.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); + if (stderr.length < 2000) stderr += chunk.toString(); }); void subprocess.catch((err: unknown) => { if (gp.ytdlProcess === subprocess) { diff --git a/package.json b/package.json index cc9c0ac..748e8cc 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "scripts": { "start": "tsx index.ts", + "test": "tsx --test \"**/*.test.ts\"", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", diff --git a/scheduler/handler.ts b/scheduler/handler.ts index b7396bf..e8eede0 100644 --- a/scheduler/handler.ts +++ b/scheduler/handler.ts @@ -1,4 +1,11 @@ -import { Message, Client, TextChannel, ChatInputCommandInteraction } from "discord.js"; +import { + Message, + Client, + TextChannel, + ChatInputCommandInteraction, + MessageFlags, +} from "discord.js"; +import { onEveryKstMinute } from "../utils.js"; import { addSchedule, getAllSchedules, @@ -15,6 +22,7 @@ type SetupStep = interface PendingState { step: SetupStep; + startedAt: number; channelId?: string; channelName?: string; message?: string; @@ -28,8 +36,20 @@ interface PendingState { const DAY_NAMES = ["일", "월", "화", "수", "목", "금", "토"]; +// 설정 마법사를 시작해 놓고 잊어버리면, 그 뒤 채널에 치는 아무 말이나 마법사 입력으로 먹혀 버린다. +// 일정 시간이 지나면 자동으로 취소한다. +const PENDING_TTL_MS = 10 * 60 * 1000; const pendingSetup = new Map(); +function getPending(key: string): PendingState | undefined { + const state = pendingSetup.get(key); + if (state && Date.now() - state.startedAt > PENDING_TTL_MS) { + pendingSetup.delete(key); + return undefined; + } + return state; +} + function pendingKey(userId: string, guildId: string): string { return `${userId}:${guildId}`; } @@ -180,56 +200,124 @@ function shouldFire(s: Schedule, kst: Date): boolean { } } -export function initScheduler(client: Client): void { - let lastFiredMinute = -1; - let isRunning = false; +// 활성 스케줄을 메모리에 들고 있다가 매분 여기서 판정한다 (예전엔 매분 DB 전체 조회). +// 등록/삭제 시 다시 읽고, 혹시 모를 외부 변경에 대비해 매시 정각에도 다시 읽는다. +let activeSchedules: Schedule[] = []; - setInterval(async () => { - const kst = new Date(Date.now() + 9 * 60 * 60 * 1000); - const minuteKey = kst.getUTCHours() * 60 + kst.getUTCMinutes(); +async function reloadSchedules(): Promise { + activeSchedules = await getAllSchedules(); +} + +function reloadSchedulesInBackground(): void { + reloadSchedules().catch((e: Error) => console.error("스케줄 조회 실패:", e.message)); +} - if (minuteKey === lastFiredMinute) return; - if (isRunning) return; +export async function initScheduler(client: Client): Promise { + await reloadSchedules().catch((e: Error) => console.error("스케줄 조회 실패:", e.message)); + // 분마다 발송 대상이 겹치지 않으므로, 앞 분 발송이 늦어져도 다음 분을 건너뛰지 않고 그대로 진행한다 + onEveryKstMinute(async (kst) => { + // 다시 읽기에 실패해도 들고 있던 목록으로 발송은 계속한다 + if (kst.getUTCMinutes() === 0) { + await reloadSchedules().catch((e: Error) => console.error("스케줄 조회 실패:", e.message)); + } + await fireSchedules(client, kst); + }); +} - isRunning = true; - lastFiredMinute = minuteKey; +async function fireSchedules(client: Client, kst: Date): Promise { + const due = activeSchedules.filter((s) => shouldFire(s, kst)); + await Promise.all( + due.map(async (s) => { + let channel = client.channels.cache.get(s.channel_id); + if (!channel) { + try { + channel = (await client.channels.fetch(s.channel_id)) ?? undefined; + } catch (e) { + console.error(`채널 페치 실패 (채널 ${s.channel_id}):`, (e as Error).message); + } + } + if (channel?.isTextBased()) { + await (channel as TextChannel).send(s.message).catch((e: Error) => { + console.error(`메시지 전송 실패 (채널 ${s.channel_id}):`, e.message); + }); + } - try { - let schedules: Schedule[]; - try { - schedules = await getAllSchedules(); - } catch (e) { - console.error("스케줄 조회 실패:", (e as Error).message); - return; + if (s.schedule_type === "once") { + activeSchedules = activeSchedules.filter((x) => x.id !== s.id); + await deactivateSchedule(s.id).catch((e: Error) => { + console.error(`스케줄 비활성화 실패 (id ${s.id}):`, e.message); + }); } + }), + ); +} - for (const s of schedules) { - if (!shouldFire(s, kst)) continue; +interface NewSchedule { + channelId: string; + channelName: string; + message: string; + hour: number; + minute: number; + scheduleType: ScheduleType; + weekdaysJson: string | null; + dayOfMonth: number | null; + targetDate: string | null; +} - let channel = client.channels.cache.get(s.channel_id); - if (!channel) { - try { - channel = (await client.channels.fetch(s.channel_id)) ?? undefined; - } catch (e) { - console.error(`채널 페치 실패 (채널 ${s.channel_id}):`, (e as Error).message); - } - } - if (channel?.isTextBased()) { - (channel as TextChannel).send(s.message).catch((e: Error) => { - console.error(`메시지 전송 실패 (채널 ${s.channel_id}):`, e.message); - }); - } +// 저장하고 확인 문구를 돌려준다 (텍스트 마법사 / 슬래시 공통) +async function saveSchedule(guildId: string, n: NewSchedule): Promise { + await addSchedule( + guildId, + n.channelId, + n.channelName, + sanitizeMessage(n.message), + n.hour, + n.minute, + n.scheduleType, + n.weekdaysJson, + n.dayOfMonth, + n.targetDate, + ); + reloadSchedulesInBackground(); + const hh = String(n.hour).padStart(2, "0"); + const mm = String(n.minute).padStart(2, "0"); + const label = formatScheduleLabel(n.scheduleType, n.weekdaysJson, n.dayOfMonth, n.targetDate); + return `✅ **[${label}] ${hh}:${mm}**에 **#${n.channelName}** 채널로 메세지를 보낼게요.`; +} - if (s.schedule_type === "once") { - deactivateSchedule(s.id).catch((e: Error) => { - console.error(`스케줄 비활성화 실패 (id ${s.id}):`, e.message); - }); - } - } - } finally { - isRunning = false; +// 디스코드 메시지는 2000자 제한이 있어서 알림이 많으면 목록이 전송 자체가 실패했다 +const MAX_LIST_LENGTH = 1900; + +async function listSchedulesText(guildId: string): Promise { + const schedules = await getSchedules(guildId); + if (schedules.length === 0) return "📭 등록된 알림이 없습니다."; + + let text = "📋 **등록된 알림 목록**"; + for (const [i, s] of schedules.entries()) { + const hh = String(s.hour).padStart(2, "0"); + const mm = String(s.minute).padStart(2, "0"); + const line = `\n${s.id}. **[${scheduleLabel(s)}] ${hh}:${mm}** → **#${s.channel_name}** — ${s.message}`; + if (text.length + line.length > MAX_LIST_LENGTH) { + text += `\n… 외 ${schedules.length - i}개`; + break; } - }, 30 * 1000); + text += line; + } + return text; +} + +async function deleteAllSchedulesText(guildId: string): Promise { + const count = await deleteAllSchedules(guildId); + if (count > 0) reloadSchedulesInBackground(); + return count === 0 + ? "📭 삭제할 알림이 없습니다." + : `✅ 이 서버의 알림 **${count}개**를 모두 삭제했습니다.`; +} + +async function deleteScheduleText(guildId: string, num: number): Promise { + const deleted = await deleteSchedule(num, guildId); + if (deleted) reloadSchedulesInBackground(); + return deleted ? `✅ ${num}번 알림을 삭제했습니다.` : `❌ ${num}번 알림을 찾을 수 없습니다.`; } async function saveAndConfirm( @@ -237,30 +325,18 @@ async function saveAndConfirm( state: PendingState, guildId: string, ): Promise { - const weekdaysJson = state.weekdays ? JSON.stringify(state.weekdays) : null; - await addSchedule( - guildId, - state.channelId!, - state.channelName!, - sanitizeMessage(state.message!), - state.hour!, - state.minute!, - state.scheduleType!, - weekdaysJson, - state.dayOfMonth ?? null, - state.targetDate ?? null, - ); - const hh = String(state.hour).padStart(2, "0"); - const mm = String(state.minute).padStart(2, "0"); - const label = formatScheduleLabel( - state.scheduleType!, - weekdaysJson, - state.dayOfMonth ?? null, - state.targetDate ?? null, - ); - await message.reply( - `✅ **[${label}] ${hh}:${mm}**에 **#${state.channelName}** 채널로 메세지를 보낼게요.`, - ); + const text = await saveSchedule(guildId, { + channelId: state.channelId!, + channelName: state.channelName!, + message: state.message!, + hour: state.hour!, + minute: state.minute!, + scheduleType: state.scheduleType!, + weekdaysJson: state.weekdays ? JSON.stringify(state.weekdays) : null, + dayOfMonth: state.dayOfMonth ?? null, + targetDate: state.targetDate ?? null, + }); + await message.reply(text); } const SCHEDULE_TYPE_MENU = `🔄 반복 유형을 선택해주세요: @@ -279,8 +355,10 @@ export async function handleScheduler(message: Message): Promise { const guildId = message.guild.id; const key = pendingKey(userId, guildId); + const state = getPending(key); + if (content === "!보내기취소") { - if (pendingSetup.has(key)) { + if (state) { pendingSetup.delete(key); message.reply("✅ 설정을 취소했습니다."); } else { @@ -289,9 +367,7 @@ export async function handleScheduler(message: Message): Promise { return true; } - if (pendingSetup.has(key)) { - const state = pendingSetup.get(key)!; - + if (state) { if (state.step === "channel") { const mentioned = message.mentions.channels.first(); if (!mentioned) { @@ -404,7 +480,7 @@ export async function handleScheduler(message: Message): Promise { } if (content === "!보내기") { - pendingSetup.set(key, { step: "channel" }); + pendingSetup.set(key, { step: "channel", startedAt: Date.now() }); message.reply( "📌 어떤 채널에 보낼까요? 채널을 멘션해주세요. 예: `#일반`\n(취소: `!보내기취소`)", ); @@ -412,44 +488,22 @@ export async function handleScheduler(message: Message): Promise { } if (content === "!알림목록") { - const schedules = await getSchedules(guildId); - if (schedules.length === 0) { - message.reply("📭 등록된 알림이 없습니다."); - } else { - const list = schedules - .map((s) => { - const hh = String(s.hour).padStart(2, "0"); - const mm = String(s.minute).padStart(2, "0"); - return `${s.id}. **[${scheduleLabel(s)}] ${hh}:${mm}** → **#${s.channel_name}** — ${s.message}`; - }) - .join("\n"); - message.reply(`📋 **등록된 알림 목록**\n${list}`); - } + await message.reply(await listSchedulesText(guildId)); return true; } if (content === "!알림삭제전체") { - const count = await deleteAllSchedules(guildId); - if (count === 0) { - message.reply("📭 삭제할 알림이 없습니다."); - } else { - message.reply(`✅ 이 서버의 알림 **${count}개**를 모두 삭제했습니다.`); - } + await message.reply(await deleteAllSchedulesText(guildId)); return true; } if (content.startsWith("!알림삭제")) { const num = parseInt(content.slice("!알림삭제".length).trim()); - if (isNaN(num)) { - message.reply("❌ 올바른 번호를 입력하세요. `!알림목록`으로 번호를 확인하세요."); - } else { - const deleted = await deleteSchedule(num, guildId); - if (deleted) { - message.reply(`✅ ${num}번 알림을 삭제했습니다.`); - } else { - message.reply(`❌ ${num}번 알림을 찾을 수 없습니다.`); - } - } + await message.reply( + isNaN(num) + ? "❌ 올바른 번호를 입력하세요. `!알림목록`으로 번호를 확인하세요." + : await deleteScheduleText(guildId, num), + ); return true; } @@ -462,7 +516,7 @@ export async function handleSchedulerSlash( if (!interaction.guildId) { await interaction.reply({ content: "❌ 이 명령어는 서버에서만 사용할 수 있습니다.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -483,7 +537,7 @@ export async function handleSchedulerSlash( if (!match) { await interaction.reply({ content: "❌ 시간 형식이 올바르지 않습니다. 예: `08:30`", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -492,7 +546,7 @@ export async function handleSchedulerSlash( if (hour > 23 || minute > 59) { await interaction.reply({ content: "❌ 올바른 시간을 입력하세요. (00:00 ~ 23:59)", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -505,7 +559,7 @@ export async function handleSchedulerSlash( if (!weekdayStr) { await interaction.reply({ content: "❌ `매주` 반복은 `요일` 옵션이 필요합니다. 예: `월,금`", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -513,7 +567,7 @@ export async function handleSchedulerSlash( if (!days) { await interaction.reply({ content: "❌ 요일 형식이 올바르지 않습니다. 예: `월,금` 또는 `1,5`", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -522,7 +576,7 @@ export async function handleSchedulerSlash( if (!dateStr) { await interaction.reply({ content: "❌ `매월` 반복은 `날짜` 옵션에 날짜(1~31)를 입력하세요.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -530,7 +584,7 @@ export async function handleSchedulerSlash( if (isNaN(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) { await interaction.reply({ content: "❌ 날짜는 1~31 사이의 숫자로 입력하세요.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -538,7 +592,7 @@ export async function handleSchedulerSlash( if (!dateStr) { await interaction.reply({ content: "❌ `1회` 반복은 `날짜` 옵션에 날짜(YYYY-MM-DD)를 입력하세요.", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } @@ -546,71 +600,39 @@ export async function handleSchedulerSlash( if (!targetDate) { await interaction.reply({ content: "❌ 날짜 형식이 올바르지 않습니다. 예: `2026-07-04`", - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; } } - const sanitized = sanitizeMessage(msg); - await addSchedule( - guildId, - channel.id, - channel.name, - sanitized, + const text = await saveSchedule(guildId, { + channelId: channel.id, + channelName: channel.name, + message: msg, hour, minute, - repeatType, + scheduleType: repeatType, weekdaysJson, dayOfMonth, targetDate, - ); - - const hh = String(hour).padStart(2, "0"); - const mm = String(minute).padStart(2, "0"); - const label = formatScheduleLabel(repeatType, weekdaysJson, dayOfMonth, targetDate); - await interaction.reply( - `✅ **[${label}] ${hh}:${mm}**에 **#${channel.name}** 채널로 메세지를 보낼게요.`, - ); + }); + await interaction.reply(text); break; } - case "알림목록": { - const schedules = await getSchedules(guildId); - if (schedules.length === 0) { - await interaction.reply("📭 등록된 알림이 없습니다."); - } else { - const list = schedules - .map((s) => { - const hh = String(s.hour).padStart(2, "0"); - const mm = String(s.minute).padStart(2, "0"); - return `${s.id}. **[${scheduleLabel(s)}] ${hh}:${mm}** → **#${s.channel_name}** — ${s.message}`; - }) - .join("\n"); - await interaction.reply(`📋 **등록된 알림 목록**\n${list}`); - } + case "알림목록": + await interaction.reply(await listSchedulesText(guildId)); break; - } - case "알림삭제전체": { - const count = await deleteAllSchedules(guildId); - if (count === 0) { - await interaction.reply("📭 삭제할 알림이 없습니다."); - } else { - await interaction.reply(`✅ 이 서버의 알림 **${count}개**를 모두 삭제했습니다.`); - } + case "알림삭제전체": + await interaction.reply(await deleteAllSchedulesText(guildId)); break; - } - case "알림삭제": { - const num = interaction.options.getInteger("번호", true); - const deleted = await deleteSchedule(num, guildId); - if (deleted) { - await interaction.reply(`✅ ${num}번 알림을 삭제했습니다.`); - } else { - await interaction.reply(`❌ ${num}번 알림을 찾을 수 없습니다.`); - } + case "알림삭제": + await interaction.reply( + await deleteScheduleText(guildId, interaction.options.getInteger("번호", true)), + ); break; - } } } diff --git a/status/handler.ts b/status/handler.ts index bbf00c0..5c14dd7 100644 --- a/status/handler.ts +++ b/status/handler.ts @@ -1,6 +1,5 @@ import { EmbedBuilder, Message, Client, ChatInputCommandInteraction } from "discord.js"; -import https from "https"; -import { kstNow, toNeisDateStr, NEIS_KEY, ATPT_CODE, SCHOOL_CODE } from "../utils.js"; +import { kstNow, toNeisDateStr, fetchNeis } from "../utils.js"; import { ping as dbPing } from "../db.js"; import { Ctx, ctxFromMessage, ctxFromInteraction } from "../ctx.js"; @@ -10,34 +9,19 @@ interface ApiStatus { error?: string; } -function checkNeis(): Promise { - const dateStr = toNeisDateStr(kstNow()); - const url = - `https://open.neis.go.kr/hub/mealServiceDietInfo` + - `?KEY=${NEIS_KEY}&Type=json&pIndex=1&pSize=1` + - `&ATPT_OFCDC_SC_CODE=${ATPT_CODE}` + - `&SD_SCHUL_CODE=${SCHOOL_CODE}` + - `&MLSV_YMD=${dateStr}` + - `&MMEAL_SC_CODE=2`; - - return new Promise((resolve) => { - const start = Date.now(); - const req = https.get(url, (res) => { - res.resume(); - if (res.statusCode !== 200) { - resolve({ ok: false, ms: null, error: `HTTP ${res.statusCode}` }); - } else { - resolve({ ok: true, ms: Date.now() - start }); - } - }); - req.setTimeout(5000, () => { - req.destroy(); - resolve({ ok: false, ms: null, error: "timeout (5s 초과)" }); - }); - req.on("error", (err: Error) => { - resolve({ ok: false, ms: null, error: err.message }); - }); - }); +async function checkNeis(): Promise { + const start = Date.now(); + try { + await fetchNeis( + "mealServiceDietInfo", + { pIndex: 1, pSize: 1, MLSV_YMD: toNeisDateStr(kstNow()), MMEAL_SC_CODE: 2 }, + 5000, + ); + return { ok: true, ms: Date.now() - start }; + } catch (err) { + const msg = (err as Error).message; + return { ok: false, ms: null, error: msg === "요청 시간 초과" ? "timeout (5s 초과)" : msg }; + } } async function executeStatus(ctx: Ctx, client: Client): Promise { @@ -50,6 +34,7 @@ async function executeStatus(ctx: Ctx, client: Client): Promise { const wsPing = client.ws.ping; const memMB = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1); + await ctx.defer(); const [neis, db] = await Promise.allSettled([ checkNeis(), (async (): Promise => { diff --git a/timetable/handler.ts b/timetable/handler.ts index 73ed180..07c12eb 100644 --- a/timetable/handler.ts +++ b/timetable/handler.ts @@ -1,18 +1,16 @@ import { EmbedBuilder, Message, ChatInputCommandInteraction, GuildMember } from "discord.js"; -import https from "https"; -import { - kstNow, - toNeisDateStr, - fetchWithRetry, - NEIS_KEY, - ATPT_CODE, - SCHOOL_CODE, -} from "../utils.js"; +import { kstNow, toNeisDateStr, fetchWithRetry, fetchNeis } from "../utils.js"; import { Ctx, ctxFromMessage, ctxFromInteraction } from "../ctx.js"; +import { TtlCache } from "../cache.js"; const DAY_NAMES = ["일", "월", "화", "수", "목", "금", "토"]; const CLASS_COLORS = [0x3b82f6, 0x10b981, 0xf59e0b, 0x8b5cf6, 0xef4444, 0xec4899]; +// 시간표는 하루 중에 거의 바뀌지 않는다. 방학/주말처럼 데이터가 없는 경우도 짧게 캐시한다. +const TIMETABLE_TTL_MS = 30 * 60 * 1000; +const NO_TIMETABLE_TTL_MS = 10 * 60 * 1000; +const timetableCache = new TtlCache(300); + interface ClassInfo { grade: number; classNum: number; @@ -23,17 +21,14 @@ interface TimetableRow { subject: string; } -function getClassFromRoles(member: NonNullable): ClassInfo | null { +// "3반" → 2학년 3반, "2-1" → 2학년 1반 +function getClassFromRoles(member: GuildMember): ClassInfo | null { for (const role of member.roles.cache.values()) { const simpleMatch = role.name.match(/^(\d+)반$/); if (simpleMatch) return { grade: 2, classNum: parseInt(simpleMatch[1]) }; const fullMatch = role.name.match(/^(\d+)-(\d+)$/); - if (fullMatch) - return { - grade: parseInt(fullMatch[1]), - classNum: parseInt(fullMatch[2]), - }; + if (fullMatch) return { grade: parseInt(fullMatch[1]), classNum: parseInt(fullMatch[2]) }; } return null; } @@ -51,56 +46,32 @@ function getTargetDate(): Date { return target; } -function fetchTimetable( +async function fetchTimetable( dateStr: string, grade: number, classNum: number, ): Promise { - const url = - `https://open.neis.go.kr/hub/hisTimetable` + - `?KEY=${NEIS_KEY}&Type=json&pIndex=1&pSize=100` + - `&ATPT_OFCDC_SC_CODE=${ATPT_CODE}` + - `&SD_SCHUL_CODE=${SCHOOL_CODE}` + - `&ALL_TI_YMD=${dateStr}` + - `&GRADE=${grade}` + - `&CLASS_NM=${classNum}`; - - console.log(`[NEIS] 시간표 요청 — ${dateStr} grade=${grade} class=${classNum}`); - 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.hisTimetable) { - resolve(null); - return; - } - const rows = json.hisTimetable[1].row; - rows.sort((a: any, b: any) => parseInt(a.PERIO) - parseInt(b.PERIO)); - resolve( - rows.map((r: any) => ({ - period: parseInt(r.PERIO), - subject: r.ITRT_CNTNT.trim(), - })), - ); - } catch (e) { - reject(e); - } - }); - }); - req.setTimeout(8000, () => req.destroy(new Error("NEIS API 요청 시간 초과"))); - req.on("error", reject); + const json = await fetchNeis("hisTimetable", { + pIndex: 1, + pSize: 100, + ALL_TI_YMD: dateStr, + GRADE: grade, + CLASS_NM: classNum, }); + const rows: any[] | undefined = json.hisTimetable?.[1]?.row; + if (!rows) return null; + + // NEIS가 같은 교시를 중복으로 주는 경우가 있어서 저장 전에 한 번 정리해 둔다 + const seen = new Set(); + return rows + .map((r) => ({ period: parseInt(r.PERIO), subject: String(r.ITRT_CNTNT).trim() })) + .sort((a, b) => a.period - b.period) + .filter((r) => { + const key = `${r.period}:${r.subject}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); } async function executeTimetable(ctx: Ctx, grade: number, classNum: number): Promise { @@ -111,20 +82,22 @@ async function executeTimetable(ctx: Ctx, grade: number, classNum: number): Prom const day = target.getUTCDate(); try { - const rows = await fetchWithRetry(() => fetchTimetable(dateStr, grade, classNum)); + const cacheKey = `${dateStr}:${grade}:${classNum}`; + let rows = timetableCache.get(cacheKey); + if (rows === undefined) { + await ctx.defer(); + rows = await timetableCache.getOrLoad( + cacheKey, + () => fetchWithRetry(() => fetchTimetable(dateStr, grade, classNum)), + (r) => (r && r.length > 0 ? TIMETABLE_TTL_MS : NO_TIMETABLE_TTL_MS), + ); + } if (!rows || rows.length === 0) { ctx.reply(`😢 ${month}월 ${day}일(${dayName}) 시간표 정보가 없습니다.`); return; } - const seen = new Set(); - const deduped = rows.filter((r) => { - const key = `${r.period}:${r.subject}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - const lines = deduped.map((r) => `**${r.period}교시** - ${r.subject}`); + const lines = rows.map((r) => `**${r.period}교시** - ${r.subject}`); const color = CLASS_COLORS[(classNum - 1) % CLASS_COLORS.length]; const embed = new EmbedBuilder() @@ -149,30 +122,33 @@ async function executeTimetable(ctx: Ctx, grade: number, classNum: number): Prom } } +const CLASS_INPUT_ERROR = "❌ 학년은 1~3, 반은 1 이상의 숫자로 입력해줘! (예: `2-3`)"; +const NO_ROLE_ERROR = "❌ 반 역할이 없습니다. (예: `1반`, `2-1`) 관리자에게 문의하세요."; + +function isValidClass(grade: number, classNum: number): boolean { + return grade >= 1 && grade <= 3 && classNum >= 1; +} + export async function handleTimetable(message: Message): Promise { - const cmd = message.content.trim(); - const match = cmd.match(/^!(?:시간표|ㅅㄱㅍ)\s*(?:(\d)-(\d+))?$/); + const match = message.content.trim().match(/^!(?:시간표|ㅅㄱㅍ)\s*(?:(\d)-(\d+))?$/); if (!match) return false; - let grade: number, classNum: number; - + let info: ClassInfo | null; if (match[1] && match[2]) { - grade = parseInt(match[1]); - classNum = parseInt(match[2]); - if (grade < 1 || grade > 3 || classNum < 1) { + info = { grade: parseInt(match[1]), classNum: parseInt(match[2]) }; + if (!isValidClass(info.grade, info.classNum)) { message.reply("❌ 학년은 1~3, 반은 1 이상의 숫자로 입력해줘! (예: `!시간표 2-3`)"); return true; } } else { - const info = getClassFromRoles(message.member!); + info = message.member ? getClassFromRoles(message.member) : null; if (!info) { - message.reply("❌ 반 역할이 없습니다. (예: `1반`, `2-1`) 관리자에게 문의하세요."); + message.reply(NO_ROLE_ERROR); return true; } - ({ grade, classNum } = info); } - await executeTimetable(ctxFromMessage(message), grade, classNum); + await executeTimetable(ctxFromMessage(message), info.grade, info.classNum); return true; } @@ -181,18 +157,16 @@ export async function handleTimetableSlash( ): Promise { const input = interaction.options.getString("학년반"); - let grade: number, classNum: number; - + let info: ClassInfo | null; if (input) { const match = input.match(/^(\d)-(\d+)$/); if (!match) { await interaction.reply("❌ 올바른 형식으로 입력해줘! (예: `2-3`)"); return; } - grade = parseInt(match[1]); - classNum = parseInt(match[2]); - if (grade < 1 || grade > 3 || classNum < 1) { - await interaction.reply("❌ 학년은 1~3, 반은 1 이상의 숫자로 입력해줘! (예: `2-3`)"); + info = { grade: parseInt(match[1]), classNum: parseInt(match[2]) }; + if (!isValidClass(info.grade, info.classNum)) { + await interaction.reply(CLASS_INPUT_ERROR); return; } } else { @@ -203,25 +177,12 @@ export async function handleTimetableSlash( ); return; } - let found: ClassInfo | null = null; - for (const role of member.roles.cache.values()) { - const simpleMatch = role.name.match(/^(\d+)반$/); - if (simpleMatch) { - found = { grade: 2, classNum: parseInt(simpleMatch[1]) }; - break; - } - const fullMatch = role.name.match(/^(\d+)-(\d+)$/); - if (fullMatch) { - found = { grade: parseInt(fullMatch[1]), classNum: parseInt(fullMatch[2]) }; - break; - } - } - if (!found) { - await interaction.reply("❌ 반 역할이 없습니다. (예: `1반`, `2-1`) 관리자에게 문의하세요."); + info = getClassFromRoles(member); + if (!info) { + await interaction.reply(NO_ROLE_ERROR); return; } - ({ grade, classNum } = found); } - await executeTimetable(ctxFromInteraction(interaction), grade, classNum); + await executeTimetable(ctxFromInteraction(interaction), info.grade, info.classNum); } diff --git a/utils.ts b/utils.ts index d62a5fe..3f23b99 100644 --- a/utils.ts +++ b/utils.ts @@ -36,13 +36,60 @@ async function fetchWithRetry(fn: () => Promise, maxRetries = 2): Promise< lastErr = err; if (attempt <= maxRetries) { console.warn(`[NEIS] 재시도 ${attempt}/${maxRetries} — ${(err as Error).message}`); - await new Promise((r) => setTimeout(r, 1000 * attempt)); + await new Promise((r) => setTimeout(r, 300)); } } } throw lastErr; } +// 전역 fetch(undici)는 연결을 재사용(keep-alive)해서, 매 요청마다 TLS 핸드셰이크를 새로 하던 +// https.get보다 연속 요청이 빠르다. +async function fetchJson(url: string, timeoutMs = 4000): Promise { + let res: Response; + try { + res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + } catch (err) { + if ((err as Error).name === "TimeoutError") throw new Error("요청 시간 초과"); + throw err; + } + if (!res.ok) { + await res.body?.cancel(); + throw new Error(`HTTP ${res.status}`); + } + return (await res.json()) as T; +} + +// NEIS Open API 공통 호출 — 학교 코드와 인증키는 여기서 채운다 +function fetchNeis( + service: string, + params: Record, + timeoutMs?: number, +): Promise { + const query = new URLSearchParams({ + KEY: NEIS_KEY, + Type: "json", + ATPT_OFCDC_SC_CODE: ATPT_CODE, + SD_SCHUL_CODE: SCHOOL_CODE, + }); + for (const [k, v] of Object.entries(params)) query.set(k, String(v)); + return fetchJson(`https://open.neis.go.kr/hub/${service}?${query}`, timeoutMs); +} + +// KST 기준 분이 바뀔 때마다 한 번씩 콜백을 부른다 (30초 폴링이라 같은 분에 두 번 불리지 않게 막는다) +function onEveryKstMinute(cb: (kst: Date) => void | Promise): void { + let lastFiredMinute = -1; + setInterval(() => { + const kst = kstNow(); + const minuteKey = kst.getUTCHours() * 60 + kst.getUTCMinutes(); + if (minuteKey === lastFiredMinute) return; + lastFiredMinute = minuteKey; + Promise.resolve() + .then(() => cb(kst)) + .catch((err) => console.error("[Timer]", (err as Error).message)); + }, 30 * 1000); +} + const fallbackFileCache = new Map(); const fallbackMtimeCache = new Map(); @@ -90,6 +137,9 @@ export { toKSTDateStr, toNeisDateStr, fetchWithRetry, + fetchJson, + fetchNeis, + onEveryKstMinute, getFallbackMeal, getFallbackSchedule, NEIS_KEY, diff --git a/weather/cache.ts b/weather/cache.ts index e659c1a..87b0f10 100644 --- a/weather/cache.ts +++ b/weather/cache.ts @@ -1,12 +1,11 @@ -import https from "https"; -import { kstNow } from "../utils.js"; +import { kstNow, fetchJson, onEveryKstMinute } from "../utils.js"; +import { TtlCache } from "../cache.js"; const KMA_KEY = process.env.KMA_API_KEY; const AIR_KEY = process.env.AIR_API_KEY; const NX = process.env.WEATHER_NX ?? "58"; // 광주광역시 const NY = process.env.WEATHER_NY ?? "74"; const AIR_STATION = process.env.AIR_STATION ?? "광산구"; -const CACHE_TTL = 10 * 60 * 1000; export interface WeatherData { temp: number; @@ -17,31 +16,10 @@ export interface WeatherData { dust: string; } -let cached: WeatherData | null = null; -let cachedAt = 0; - -function fetchJson(url: string): Promise { - 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", (c: string) => (raw += c)); - res.on("end", () => { - try { - resolve(JSON.parse(raw)); - } catch (e) { - reject(e); - } - }); - }); - req.setTimeout(8000, () => req.destroy(new Error("timeout"))); - req.on("error", reject); - }); -} +// 30분마다 예열하므로 TTL을 조금 더 길게 둬서 예열 사이에 캐시가 비지 않게 한다 +const WEATHER_TTL_MS = 40 * 60 * 1000; +const weatherCache = new TtlCache(1); +const WEATHER_KEY = "current"; // 초단기실황: 매시각 :40 이후 발표 function ultraSrtBase(kst: Date): { base_date: string; base_time: string } { @@ -119,8 +97,18 @@ function toDustLevel(pm10: number): string { return "매우나쁨"; } -export async function getWeatherData(): Promise { - if (cached && Date.now() - cachedAt < CACHE_TTL) return cached; +export function peekWeatherData(): WeatherData | undefined { + return weatherCache.get(WEATHER_KEY); +} + +// 동시에 여러 명이 !날씨를 쳐도 API 3종 호출은 한 번만 나간다 +export function getWeatherData(force = false): Promise { + return force + ? weatherCache.load(WEATHER_KEY, fetchWeather, () => WEATHER_TTL_MS) + : weatherCache.getOrLoad(WEATHER_KEY, fetchWeather, () => WEATHER_TTL_MS); +} + +async function fetchWeather(): Promise { if (!KMA_KEY) throw new Error("KMA_API_KEY 환경변수 없음"); if (!AIR_KEY) throw new Error("AIR_API_KEY 환경변수 없음"); @@ -192,7 +180,7 @@ export async function getWeatherData(): Promise { const airItem = air.response.body.items?.[0]; const pm10Raw = parseFloat(airItem?.pm10Value ?? airItem?.pm10Value24 ?? "50"); - cached = { + return { temp, feels_like: calcFeelsLike(temp, wsd, reh), status: toStatus(sky, ptyNow || ptyFcst), @@ -200,6 +188,13 @@ export async function getWeatherData(): Promise { temp_min: tempMin, dust: toDustLevel(isNaN(pm10Raw) ? 50 : pm10Raw), }; - cachedAt = Date.now(); - return cached; +} + +export function initWeatherCacheWarmer(): void { + onEveryKstMinute(async (kst) => { + if (kst.getUTCMinutes() % 30 !== 0) return; + await getWeatherData(true).catch((err) => + console.error("[Weather] 캐시 예열 실패:", (err as Error).message), + ); + }); } diff --git a/weather/handler.ts b/weather/handler.ts index ba67591..ccde158 100644 --- a/weather/handler.ts +++ b/weather/handler.ts @@ -1,5 +1,5 @@ import { EmbedBuilder, Message, ChatInputCommandInteraction } from "discord.js"; -import { getWeatherData } from "./cache.js"; +import { getWeatherData, peekWeatherData } from "./cache.js"; import { Ctx, ctxFromMessage, ctxFromInteraction } from "../ctx.js"; const STATUS_EMOJI: Record = { @@ -10,10 +10,11 @@ const STATUS_EMOJI: Record = { 눈: "❄️", }; const DUST_EMOJI: Record = { 좋음: "🟢", 보통: "🟡", 나쁨: "🔴", 매우나쁨: "🟣" }; -const COMMANDS = ["!날씨", "!ㄴㅆ"]; +const COMMANDS = new Set(["!날씨", "!ㄴㅆ"]); async function executeWeather(ctx: Ctx): Promise { try { + if (!peekWeatherData()) await ctx.defer(); const d = await getWeatherData(); const embed = new EmbedBuilder() .setColor(0xff8a00) @@ -32,7 +33,7 @@ async function executeWeather(ctx: Ctx): Promise { } export async function handleWeather(message: Message): Promise { - if (!COMMANDS.includes(message.content.trim())) return false; + if (!COMMANDS.has(message.content.trim())) return false; await executeWeather(ctxFromMessage(message)); return true; }