From d0dcdb1563e9cf965e0a3a7e129bcc8b04ef1345 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 26 Apr 2026 23:13:16 +0900 Subject: [PATCH 1/5] =?UTF-8?q?bot=20=EC=A0=84=EC=B2=B4=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=92=88=EC=A7=88=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GuildPlayer: 타이머 레이스 컨디션 수정, 모든 타이머 정리 로직 통합 (clearAllTimers) - GuildPlayer: 이벤트 핸들러에 try-catch 추가 (end, exception, stuck) - GuildPlayer: start 이벤트에서 endTimer 정리, autoPlay tracks 길이 검증 추가 - RedisClient: player_seek, player_volume에 누락된 return ���가 - RedisClient: queue_remove 인덱스 검증 주석 명확화 - Handler: runCommand에 try-catch 추가하여 에러 시 사용자에게 응답 - Channel: getGuildById에 누락된 await 추가, getMemberById/getVoiceChannelById 안전한 에러 처리 - Command.d.ts: 잘못된 타입 ChatInputChatInputCommandInteraction → ChatInputCommandInteraction 수정 - join.ts: 채널 멘션 닫는 괄호 누락 수정 - shuffle.ts: 제네릭 타입 적용, 불필요한 5회 반복 제거 - import 경로 대소문자 수정 (Shuffle → shuffle) - Linux 호환 - YoutubeMusic/Spotify: 하드코딩된 IP를 환경변수로 분리 - console.log/error → Logger 통일 (YoutubeMusic, Button, channel) - interactionCreate: 전체 try-catch 추가, silent catch에 로깅 추가 - Database: schema 경로 __dirname 기반으로 수정, 컬럼 화이트리스트 추가 - 사용하지 않는 코드 정리 (axios 의존성, 주석처리된 user 관련 코드) Co-Authored-By: Claude Opus 4.6 --- bot/db/db.d.ts | 8 +-- bot/package.json | 1 - bot/src/classes/BotClient.ts | 7 ++- bot/src/classes/GuildPlayer.ts | 93 +++++++++++++++++++---------- bot/src/classes/Handler.ts | 10 +++- bot/src/classes/LavalinkManager.ts | 2 +- bot/src/classes/RedisClient.ts | 12 ++-- bot/src/commands/channel.ts | 3 +- bot/src/commands/join.ts | 2 +- bot/src/events/interactionCreate.ts | 69 ++++++++++++--------- bot/src/events/voiceStateUpdate.ts | 6 +- bot/src/types/Command.d.ts | 6 +- bot/src/utils/Config.ts | 2 + bot/src/utils/Database.ts | 57 +++--------------- bot/src/utils/api/Spotify.ts | 2 +- bot/src/utils/api/YoutubeMusic.ts | 13 ++-- bot/src/utils/music/Button.ts | 7 ++- bot/src/utils/music/Channel.ts | 23 ++++--- bot/src/utils/shuffle.ts | 17 ++---- 19 files changed, 178 insertions(+), 162 deletions(-) diff --git a/bot/db/db.d.ts b/bot/db/db.d.ts index 386c079..0be043a 100644 --- a/bot/db/db.d.ts +++ b/bot/db/db.d.ts @@ -7,10 +7,4 @@ export interface GuildType { recommend: boolean; }; } -export type GuildRow = Omit & { options: string }; - -// export interface UserType { -// guild_id: string; -// id: string; -// name: string; -// } \ No newline at end of file +export type GuildRow = Omit & { options: string }; \ No newline at end of file diff --git a/bot/package.json b/bot/package.json index 7bce364..39df2b1 100644 --- a/bot/package.json +++ b/bot/package.json @@ -20,7 +20,6 @@ "typescript": "^6.0.2" }, "dependencies": { - "axios": "^1.14.0", "better-sqlite3": "^12.8.0", "colors": "^1.4.0", "discord.js": "^14.25.1", diff --git a/bot/src/classes/BotClient.ts b/bot/src/classes/BotClient.ts index 6fedfdf..0575888 100644 --- a/bot/src/classes/BotClient.ts +++ b/bot/src/classes/BotClient.ts @@ -1,5 +1,6 @@ import { Client, ClientEvents, ColorResolvable, EmbedBuilder, EmbedField, GatewayIntentBits, Message } from "discord.js"; import { Config } from "../utils/Config"; +import { Logger } from "../utils/Logger"; export class BotClient extends Client { public prefix = Config.prefix; @@ -69,8 +70,10 @@ export class BotClient extends Client { setTimeout(async () => { try { const msg = await message.fetch(true).catch(() => undefined); - if (msg?.deletable) msg.delete().catch(() => {}); - } catch {}; + if (msg?.deletable) msg.delete().catch((err) => { + Logger.warn(`[BotClient] 메세지 삭제 실패: ${String(err)}`); + }); + } catch {} }, Math.max(100, time * (customTime ? 1 : 6000))); } } \ No newline at end of file diff --git a/bot/src/classes/GuildPlayer.ts b/bot/src/classes/GuildPlayer.ts index 1bd7067..9948ec5 100644 --- a/bot/src/classes/GuildPlayer.ts +++ b/bot/src/classes/GuildPlayer.ts @@ -5,7 +5,7 @@ import { timeFormat } from "../utils/music/Utils"; import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config"; import { GuildType } from "../../db/db"; import { DB } from "../utils/Database"; -import { shuffle } from "../utils/Shuffle"; +import { shuffle } from "../utils/shuffle"; import { checkTextChannelAndMsg } from "../utils/music/Channel"; import { Logger } from "../utils/Logger"; @@ -22,6 +22,7 @@ export class GuildPlayer { public queue: QueueTrack[] = []; private errorTimer: NodeJS.Timeout | undefined; private endTimer: NodeJS.Timeout | undefined; + private closedTimer: NodeJS.Timeout | undefined; constructor( public guild: Guild, @@ -32,45 +33,51 @@ export class GuildPlayer { ) { this.player.setGlobalVolume(50); this.player.on("start", (_data: TrackStartEvent) => { + // endTimer가 남아있으면 제거 (새 곡 재생 시작) + if (this.endTimer !== undefined) { + clearTimeout(this.endTimer); + this.endTimer = undefined; + } Redis?.publishState("player_update", { guildId: this.guild.id, }); }); this.player.on("end", async (data: TrackEndEvent) => { - if (this.isDead) return; - if (data.reason === "replaced") return; - // 방금 끝난 곡을 대기열에서 지우면서 lastPlayedTrack에 저장 - this.lastPlayedTrack = this.queue.shift(); - if (this.queue.length > 0) { - await this.playNext(); - } else if (this.isRecommend && this.canRecommend) { - await this.autoPlay(); - } else { - this.end(); + try { + if (this.isDead) return; + if (data.reason === "replaced") return; + // 방금 끝난 곡을 대기열에서 지우면서 lastPlayedTrack에 저장 + this.lastPlayedTrack = this.queue.shift(); + if (this.queue.length > 0) { + await this.playNext(); + } else if (this.isRecommend && this.canRecommend) { + await this.autoPlay(); + } else { + this.end(); + } + } catch (err) { + Logger.error(`[GuildPlayer] end 이벤트 ��리 중 에러: ${String(err)}`); } }); this.player.on("closed", () => { if (this.isDead) return; Logger.info(`[GuildPlayer] 음성 연결이 끊어졌습니다. 재접속을 대기합니다...`); - setTimeout(() => { + // 이전 closed 타이머가 있으면 제거 + if (this.closedTimer !== undefined) { + clearTimeout(this.closedTimer); + } + this.closedTimer = setTimeout(() => { + this.closedTimer = undefined; if (this.isDead) return; // 5초가 지났는데도 연결이 복구되지 않았을 때만 방을 나갑니다. - + // 디스코드 방에 내 봇(me)이 없으면 봇을 삭제(delete)한다! if (!this.guild.members.me?.voice?.channelId) { Logger.warn(`[GuildPlayer] 음성채널에 봇이 없습니다. player를 초기화합니다.`); return this.delete(); } - /** - * declare enum State { - * CONNECTING = 0, - * CONNECTED = 1, - * DISCONNECTING = 2, - * DISCONNECTED = 3 - * } - */ - // (1 = CONNECTED, Shoukaku 버전에 따라 연결 상태 체크가 다를 수 있으니 안전하게 확인) + // (1 = CONNECTED, Shoukaku 버전에 따라 연결 상태 체크가 다를 수 있으�� 안전하게 확인) if (this.player && this.player.node.state !== 1) { Logger.warn(`[GuildPlayer] 연결 복구 실패. 봇을 퇴장시킵니다.`); return this.delete(); @@ -79,12 +86,20 @@ export class GuildPlayer { }); this.player.on("exception", async (data) => { - Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}`); - await this.errMsg("유튜브 차단 또는 재생 오류로 인해 이 곡을 건너뜁니다."); + try { + Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}`); + await this.errMsg("유튜브 차단 또는 재생 오류로 인해 이 곡을 건너뜁니다."); + } catch (err) { + Logger.error(`[GuildPlayer] exception 이벤트 처리 중 에러: ${String(err)}`); + } }); this.player.on("stuck", async (data) => { - Logger.error(`[Lavalink] 곡 로딩 멈춤(Stuck) 발생: ${data.thresholdMs}ms 초과`); - await this.errMsg("음원 로딩이 멈췄습니다. 다음 곡으로 넘어갑니다."); + try { + Logger.error(`[Lavalink] 곡 로딩 멈춤(Stuck) 발생: ${data.thresholdMs}ms 초과`); + await this.errMsg("음원 로딩이 멈췄습니다. 다음 곡으로 넘어갑니다."); + } catch (err) { + Logger.error(`[GuildPlayer] stuck 이벤트 처리 중 에러: ${String(err)}`); + } }); } @@ -214,16 +229,31 @@ export class GuildPlayer { this.end(); return; } - if (tracks[0].info.identifier === trackId) tracks = tracks.slice(1); + if (tracks.length > 0 && tracks[0].info.identifier === trackId) tracks = tracks.slice(1); + if (tracks.length === 0) { + this.end(); + return; + } this.addTracks(tracks, "자동재생"); } - public end() { + private clearAllTimers() { if (this.errorTimer !== undefined) { clearTimeout(this.errorTimer); this.errorTimer = undefined; } - if (this.endTimer !== undefined) clearTimeout(this.endTimer); + if (this.endTimer !== undefined) { + clearTimeout(this.endTimer); + this.endTimer = undefined; + } + if (this.closedTimer !== undefined) { + clearTimeout(this.closedTimer); + this.closedTimer = undefined; + } + } + + public end() { + this.clearAllTimers(); this.endTimer = setTimeout(() => { this.endTimer = undefined; this.delete(true); @@ -238,7 +268,10 @@ export class GuildPlayer { if (this.isDead) return; if (!afterEnd) this.end(); this.isDead = true; - this.player.destroy().catch(() => {}); + this.clearAllTimers(); + this.player.destroy().catch((err) => { + Logger.error(`[GuildPlayer] player.destroy 에러: ${String(err)}`); + }); lavalinkManager.delPlayer(this.guild.id); lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id); } diff --git a/bot/src/classes/Handler.ts b/bot/src/classes/Handler.ts index d0d0451..54506ff 100644 --- a/bot/src/classes/Handler.ts +++ b/bot/src/classes/Handler.ts @@ -2,6 +2,7 @@ import { ChatInputCommandInteraction, Collection } from "discord.js"; import { readdirSync } from "node:fs"; import { Command } from "../types/Command"; import { COMMAND_PATH, COMMANDS_PATH } from "../utils/Config"; +import { Logger } from "../utils/Logger"; export class Handler { public commands: Collection = new Collection(); @@ -15,11 +16,16 @@ export class Handler { } } - public runCommand(interaction: ChatInputCommandInteraction) { + public async runCommand(interaction: ChatInputCommandInteraction) { const commandName = interaction.commandName; const command = this.commands.get(commandName); if (!command) return; - if (command.slashRun) command.slashRun(interaction); + try { + if (command.slashRun) await command.slashRun(interaction); + } catch (err) { + Logger.error(`[Handler] 명령어 '${commandName}' 실행 중 에러: ${String(err)}`); + await interaction.editReply({ content: "명령어 실행 중 오류가 발생했습니다." }).catch(() => {}); + } } } \ No newline at end of file diff --git a/bot/src/classes/LavalinkManager.ts b/bot/src/classes/LavalinkManager.ts index 7f51c73..fc6cbae 100644 --- a/bot/src/classes/LavalinkManager.ts +++ b/bot/src/classes/LavalinkManager.ts @@ -4,7 +4,7 @@ import { GuildPlayer } from "./GuildPlayer"; import { Config } from "../utils/Config"; import { Logger } from "../utils/Logger"; import { parseLink } from "../utils/music/Url"; -import { shuffle } from "../utils/Shuffle"; +import { shuffle } from "../utils/shuffle"; import { Spotify } from "../utils/api/Spotify"; import { YoutubeMusic } from "../utils/api/YoutubeMusic"; diff --git a/bot/src/classes/RedisClient.ts b/bot/src/classes/RedisClient.ts index b290a61..aeec4b9 100644 --- a/bot/src/classes/RedisClient.ts +++ b/bot/src/classes/RedisClient.ts @@ -148,8 +148,10 @@ class RedisClientClass { const numIndex = Number(data.index); if (isNaN(numIndex)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." })); if (numIndex < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." })); - if (numIndex >= context.player.queue.length-1) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index가 queue.length보다 클수 없습니다." })); - const [removedTrack] = context.player.queue.splice(numIndex+1, 1); + // queue[0]은 현재 재생중인 곡이므로 실제 대기열은 queue[1]부터 시작 + // numIndex는 대기열(queue[1]~) 기준이므로 실제 splice 위치�� numIndex+1 + if (numIndex >= context.player.queue.length - 1) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." })); + const [removedTrack] = context.player.queue.splice(numIndex + 1, 1); await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, removedTrack })); context.player.setMsg(); } @@ -179,8 +181,8 @@ class RedisClientClass { if (!data.seek) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek를 찾을수 없습니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; - if (!context.player.isPlaying || !context.player.nowTrack) await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." })); - const duration = context.player.nowTrack?.info.length || 0; + if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." })); + const duration = context.player.nowTrack.info.length || 0; const numSeek = Number(data.seek); if (isNaN(numSeek)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." })); if (numSeek < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." })); @@ -195,7 +197,7 @@ class RedisClientClass { if (!data.volume) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume을 찾을수 없습니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; - if (!context.player.isPlaying || !context.player.nowTrack) await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." })); + if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." })); const numVolume = Number(data.volume); if (isNaN(numVolume)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." })); if (numVolume < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." })); diff --git a/bot/src/commands/channel.ts b/bot/src/commands/channel.ts index d2951ca..eed961c 100644 --- a/bot/src/commands/channel.ts +++ b/bot/src/commands/channel.ts @@ -4,6 +4,7 @@ import { Command } from "../types/Command"; import { clearAllMsg } from "../utils/music/Utils"; import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config"; import { DB } from "../utils/Database"; +import { Logger } from "../utils/Logger"; /** channel 명령어 */ export default class implements Command { @@ -100,7 +101,7 @@ export async function channelRegister(guild: Guild | null, channelId: string | n components: [ getButtons() ], files: [ default_image ], }).catch((err) => { - console.error(err); + Logger.error(`[Channel] 메세지 생성 실패: ${String(err)}`); return null; }); if (!msg) return client.mkembed({ diff --git a/bot/src/commands/join.ts b/bot/src/commands/join.ts index 8f1f722..5434e00 100644 --- a/bot/src/commands/join.ts +++ b/bot/src/commands/join.ts @@ -72,7 +72,7 @@ export async function channelJoin(guild: Guild | null, voiceChannelId: string | }) }; let player = lavalinkManager.getPlayer(guild.id); - if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId} 참가중입니다.` }), player }; + if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId}> 참가중입니다.` }), player }; player = new GuildPlayer( guild, await lavalinkManager.shoukaku.joinVoiceChannel({ diff --git a/bot/src/events/interactionCreate.ts b/bot/src/events/interactionCreate.ts index 23c6e17..714c137 100644 --- a/bot/src/events/interactionCreate.ts +++ b/bot/src/events/interactionCreate.ts @@ -1,36 +1,47 @@ import { Interaction, MessageFlags } from "discord.js"; import { handler } from "../index"; import { buttonInteraction } from "../utils/music/Button"; +import { Logger } from "../utils/Logger"; export const interactionCreate = async (interaction: Interaction) => { - if (interaction.isStringSelectMenu()) { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {}); - const commandName = interaction.customId; - const args = interaction.values; - const command = handler.commands.get(commandName); - if (command?.menuRun) return await command.menuRun(interaction, args); + try { + if (interaction.isStringSelectMenu()) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => { + Logger.warn(`[Interaction] SelectMenu deferReply 실패: ${String(err)}`); + }); + const commandName = interaction.customId; + const args = interaction.values; + const command = handler.commands.get(commandName); + if (command?.menuRun) return await command.menuRun(interaction, args); + } + + if (interaction.isButton()) { + const args = interaction.customId.split("-"); + if (!args || args.length === 0) return; + + if (args[0] === "music") return buttonInteraction(interaction, args.slice(1)); + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => { + Logger.warn(`[Interaction] Button deferReply 실패: ${String(err)}`); + }); + + const key = args.shift(); + if (!key) return; + const command = handler.commands.get(key); + if (command?.buttonRun) return command.buttonRun(interaction, args); + } + + if (!interaction.isChatInputCommand()) return; + + /** + * 명령어 친사람만 보이게 설정 + * flags: MessageFlags.Ephemeral + */ + await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => { + Logger.warn(`[Interaction] Command deferReply 실패: ${String(err)}`); + }); + await handler.runCommand(interaction); + } catch (err) { + Logger.error(`[Interaction] 처리 중 에러: ${String(err)}`); } - - if (interaction.isButton()) { - const args = interaction.customId.split("-"); - if (!args || args.length === 0) return; - - if (args[0] === "music") return buttonInteraction(interaction, args.slice(1)); - - await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {}); - - const key = args.shift(); - if (!key) return; - const command = handler.commands.get(key); - if (command?.buttonRun) return command.buttonRun(interaction, args); - } - - if (!interaction.isChatInputCommand()) return; - - /** - * 명령어 친사람만 보이게 설정 - * flags: MessageFlags.Ephemeral - */ - await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {}); - handler.runCommand(interaction); } \ No newline at end of file diff --git a/bot/src/events/voiceStateUpdate.ts b/bot/src/events/voiceStateUpdate.ts index 33d1b01..ed7a93c 100644 --- a/bot/src/events/voiceStateUpdate.ts +++ b/bot/src/events/voiceStateUpdate.ts @@ -1,5 +1,3 @@ +// TODO: 음성 상태 변경 이벤트 핸들러 (추후 구현) // import { VoiceState } from "discord.js"; -// import { client } from "../index"; - -// export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise => { -// } \ No newline at end of file +// export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise => {} diff --git a/bot/src/types/Command.d.ts b/bot/src/types/Command.d.ts index 1614605..c029781 100644 --- a/bot/src/types/Command.d.ts +++ b/bot/src/types/Command.d.ts @@ -1,4 +1,4 @@ -import { ButtonInteraction, ChatInputApplicationCommandData, ChatInputChatInputCommandInteraction, Message, StringSelectMenuInteraction } from "discord.js"; +import { ButtonInteraction, ChatInputApplicationCommandData, ChatInputCommandInteraction, Message, StringSelectMenuInteraction } from "discord.js"; export interface Command { /** 메세지 이름 */ @@ -13,9 +13,9 @@ export interface Command { * 등록 메타: JSON 변환된 바디 * (빌드 시 toJSON()해서 REST 등록에 사용) */ - metaData: RESTPostAPIChatInputApplicationCommandsJSONBody; + metaData: ChatInputApplicationCommandData; - slashRun?: (args: ChatInputChatInputCommandInteraction) => Promise; + slashRun?: (args: ChatInputCommandInteraction) => Promise; messageRun?: (message: Message, args: string[]) => Promise; menuRun?: (interaction: StringSelectMenuInteraction, args: string[]) => Promise; buttonRun?: (interaction: ButtonInteraction, args: string[]) => Promise; diff --git a/bot/src/utils/Config.ts b/bot/src/utils/Config.ts index c098904..91a3187 100644 --- a/bot/src/utils/Config.ts +++ b/bot/src/utils/Config.ts @@ -69,6 +69,8 @@ export const Config = { return this._youtube_cookie; }, + proxyUrl: process.env.PROXY_URL?.trim() || "", + _redis: { state: process.env.REDIS?.trim()?.toLocaleLowerCase() === "true", host: process.env.REDIS_HOST?.trim(), diff --git a/bot/src/utils/Database.ts b/bot/src/utils/Database.ts index b985391..92339d4 100644 --- a/bot/src/utils/Database.ts +++ b/bot/src/utils/Database.ts @@ -7,12 +7,18 @@ import { Logger } from "./Logger"; const database = new Database(Config.dbPath); -const schemaPath = join(process.cwd(), "db/schema.sql"); +const schemaPath = join(__dirname, "../../db/schema.sql"); const schema = readFileSync(schemaPath, "utf-8"); database.exec(schema); Logger.ready("DB 활성화!"); +// 허용되는 guild 테이블 컬럼 화이트리스��� +const GUILD_COLUMNS = new Set(["id", "name", "channel_id", "msg_id", "options"]); + +const filterKeys = (keys: string[], whitelist: Set) => + keys.filter(k => whitelist.has(k)); + const stmt = { guild: { // 전체 @@ -21,7 +27,7 @@ const stmt = { get: database.prepare("SELECT * FROM guilds WHERE ID = ?"), // 추가 insert: (data: GuildRow) => { - const keys = Object.keys(data); + const keys = filterKeys(Object.keys(data), GUILD_COLUMNS); if (keys.length === 0) throw new Error("insert: 키1개는 있어야함"); return database.prepare(`INSERT INTO guilds (${ keys.map(k => `"${k}"`).join(", ") @@ -31,35 +37,13 @@ const stmt = { }, // 수정 update: (data: GuildRow) => { - const keys = Object.keys(data).filter(k => k !== "id"); + const keys = filterKeys(Object.keys(data), GUILD_COLUMNS).filter(k => k !== "id"); if (keys.length === 0) throw new Error("update: 키1개는 있어야함"); return database.prepare(`UPDATE guilds SET ${ keys.map(k => `${k} = @${k}`).join(", ") } WHERE id = @id`).run(data); }, }, - // user: { - // // 가져오기 - // get: database.prepare("SELECT * FROM users WHERE guild_id = ? AND id = ?"), - // // 추가 - // insert: (data: UserType) => { - // const keys = Object.keys(data); - // if (keys.length === 0) throw new Error("insert: 키1개는 있어야함"); - // return database.prepare(`INSERT INTO users (${ - // keys.map(k => `"${k}"`).join(", ") - // }) VALUES (${ - // keys.map(k => `@${k}`).join(", ") - // })`).run(data); - // }, - // // 수정 - // update: (data: UserType) => { - // const keys = Object.keys(data).filter(k => k !== "guild_id" && k !== "id"); - // if (keys.length === 0) throw new Error("update: 키1개는 있어야함"); - // return database.prepare(`UPDATE users SET ${ - // keys.map(k => `${k} = @${k}`).join(", ") - // } WHERE guild_id = @guild_id AND id = @id`).run(data); - // }, - // }, }; export const DB = { @@ -91,27 +75,4 @@ export const DB = { } }, }, - // user: { - // get(guildId: string, id: string) { - // return stmt.user.get.get(guildId, id) as UserType | undefined; - // }, - // set(data: UserType) { - // try { - // stmt.user.insert(data); - // return true; - // } catch (err) { - // Logger.error(String(err)); - // return false; - // } - // }, - // update(data: UserType) { - // try { - // stmt.user.update(data); - // return true; - // } catch (err) { - // Logger.error(String(err)); - // return false; - // } - // }, - // }, }; \ No newline at end of file diff --git a/bot/src/utils/api/Spotify.ts b/bot/src/utils/api/Spotify.ts index fbd1099..ceca9c6 100644 --- a/bot/src/utils/api/Spotify.ts +++ b/bot/src/utils/api/Spotify.ts @@ -8,7 +8,7 @@ const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? ""; const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"; const SPOTIFY_API_URL = "https://api.spotify.com/v1"; -const TOKENR_URL = "http://192.168.10.5:8075/api/token"; +const TOKENR_URL = process.env.SPOTIFY_TOKENER_URL?.trim() || "http://192.168.10.5:8075/api/token"; const searchCache = new Map(); diff --git a/bot/src/utils/api/YoutubeMusic.ts b/bot/src/utils/api/YoutubeMusic.ts index 470b1b9..f21cd7d 100644 --- a/bot/src/utils/api/YoutubeMusic.ts +++ b/bot/src/utils/api/YoutubeMusic.ts @@ -3,10 +3,11 @@ import crypto from "node:crypto"; import { Cookies } from "../../types/Youtube_Cookie"; import { Config } from "../Config"; import { SongItem } from "../../types/Track"; +import { Logger } from "../Logger"; const customPREF = "tz=Asia.Seoul&hl=ko&gl=KR&last_quality=1080"; export const ORIGIN = "https://music.youtube.com"; -const proxy = new ProxyAgent('http://192.168.10.4:3128'); +const proxy = Config.proxyUrl ? new ProxyAgent(Config.proxyUrl) : undefined; const searchCache = new Map(); // 🌟 클래스 외부에 둘 상수 및 유틸리티 함수들 (내부에서만 사용됨) @@ -56,7 +57,7 @@ export const YoutubeMusic = { const missing = keys.filter((k) => !(k in cookies) && !(blocks ?? []).includes(k)); if (missing.length > 0) { - console.log("현재 입력된 쿠키 키 목록:", Object.keys(cookies)); + Logger.warn(`현재 입력된 쿠키 키 목록: ${Object.keys(cookies).join(", ")}`); throw new Error(`❌ 필수 인증 쿠키가 누락되었습니다: ${missing.join(", ")}`); } @@ -82,7 +83,7 @@ export const YoutubeMusic = { * 완벽한 쿠키 인증과 서명(SAPISIDHASH)을 사용하여 유튜브 뮤직 검색을 수행합니다. */ async getSearchFull(query: string): Promise { - console.log(`🔍 [Auth-Cookie Engine] "${query}" 데이터 추출 중 (썸네일, 재생시간 포함)...`); + Logger.log(`🔍 [Auth-Cookie Engine] "${query}" 데이터 추출 중 (썸네일, 재생시간 포함)...`); const url = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false"; @@ -109,7 +110,7 @@ export const YoutubeMusic = { query: query, params: "EgWKAQIIAWoOEAMQBBAQEAkQFRAKEBE=" }), - dispatcher: proxy + ...(proxy ? { dispatcher: proxy } : {}) }); const data: any = await response.json(); @@ -200,9 +201,9 @@ export const YoutubeMusic = { } } - return results || []; // 배열이 비어있을 경우 안전하게 null 반환 + return results; } catch (error) { - console.error("❌ getSearchFull 실행 중 에러:", error); + Logger.error(`❌ getSearchFull 실행 중 에러: ${String(error)}`); return []; } }, diff --git a/bot/src/utils/music/Button.ts b/bot/src/utils/music/Button.ts index f879fc6..5db3d05 100644 --- a/bot/src/utils/music/Button.ts +++ b/bot/src/utils/music/Button.ts @@ -3,6 +3,7 @@ import { lavalinkManager } from "../../index"; import { checkTextChannelAndMsg, getTextChannelAndMsg } from "./Channel"; import { default_content, default_embed, default_image, getButtons } from "./Config"; import { DB } from "../Database"; +import { Logger } from "../Logger"; export const buttonInteraction = (interaction: ButtonInteraction, args: string[]) => { if (!interaction.guild) return; @@ -16,7 +17,9 @@ export const buttonInteraction = (interaction: ButtonInteraction, args: string[] } else { if (args[0] === "recommend") buttonRecommend(interaction.guild); } - return interaction.deferUpdate().catch(() => {}); + return interaction.deferUpdate().catch((err) => { + Logger.warn(`[Button] deferUpdate 실패: ${String(err)}`); + }); } const buttonRecommend = async (guild: Guild) => { @@ -33,7 +36,7 @@ const buttonRecommend = async (guild: Guild) => { components: [ getButtons() ], files: [ default_image ], }).catch((err) => { - console.error(err); + Logger.error(`[Button] 메세지 수정 실패: ${String(err)}`); return null; }); } \ No newline at end of file diff --git a/bot/src/utils/music/Channel.ts b/bot/src/utils/music/Channel.ts index abbe6de..5e8dced 100644 --- a/bot/src/utils/music/Channel.ts +++ b/bot/src/utils/music/Channel.ts @@ -6,15 +6,19 @@ import { clearAllMsg } from "./Utils"; import { client } from "../../index"; export const getGuildById = async (guildId: string): Promise => { - const guild = client.guilds.cache.get(guildId)?.fetch(); + const guild = await client.guilds.cache.get(guildId)?.fetch().catch(() => undefined); if (!guild) return null; return guild; } export const getMemberById = async (guild: Guild, userId: string): Promise => { - const member = await guild.members.cache.get(userId)?.fetch(true); - if (!member) return null; - return member; + try { + const cached = guild.members.cache.get(userId); + if (!cached) return null; + return await cached.fetch(true); + } catch { + return null; + } } export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => { @@ -25,9 +29,14 @@ export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => { export const getVoiceChannelById = async (guild: Guild, userId: string): Promise => { if (!guild) return null; - const member = await guild.members.cache.get(userId)?.fetch(true); - if (!member) return null; - return getVoiceChannel(member); + try { + const cached = guild.members.cache.get(userId); + if (!cached) return null; + const member = await cached.fetch(true); + return getVoiceChannel(member); + } catch { + return null; + } } export const getTextChannelAndMsg = async (guild: Guild): Promise<{ channel?: TextChannel; msg?: Message; reason?: string; }> => { diff --git a/bot/src/utils/shuffle.ts b/bot/src/utils/shuffle.ts index ce5fe95..afa5b26 100644 --- a/bot/src/utils/shuffle.ts +++ b/bot/src/utils/shuffle.ts @@ -1,15 +1,8 @@ -export const fshuffle = (list: any[]): any[] => { - var i, j, x; - for (i=list.length; i; i-=1) { - j = Math.floor(Math.random()*i); - x = list[i-1]; - list[i-1] = list[j]; - list[j] = x; +/** Fisher-Yates 셔플 (in-place) */ +export const shuffle = (list: T[]): T[] => { + for (let i = list.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [list[i], list[j]] = [list[j], list[i]]; } return list; -} - -export const shuffle = (list: any[]): any[] => { - for (let z=0; z<5; z++) list = fshuffle(list); - return list; } \ No newline at end of file From e5f3b87b1d4b7992665aa68d9b2e682593c1b98a Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 27 Apr 2026 02:01:14 +0900 Subject: [PATCH 2/5] =?UTF-8?q?package-lock.json=20=EC=A0=95=EB=A6=AC=20(a?= =?UTF-8?q?xios=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20npm=20audit=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이전 커밋에서 제거한 axios가 lock 파일에 남아있던 것 정리 - npm audit fix로 transitive 의존성 보안 패치 적용 완료 - 보안 취약점 0건 (이전: high 2, moderate 1) Co-Authored-By: Claude Opus 4.6 --- bot/package-lock.json | 317 +++--------------------------------------- 1 file changed, 17 insertions(+), 300 deletions(-) diff --git a/bot/package-lock.json b/bot/package-lock.json index 90912a7..b295f3b 100644 --- a/bot/package-lock.json +++ b/bot/package-lock.json @@ -9,7 +9,6 @@ "version": "0.0.1", "license": "ISC", "dependencies": { - "axios": "^1.14.0", "better-sqlite3": "^12.8.0", "colors": "^1.4.0", "discord.js": "^14.25.1", @@ -40,9 +39,9 @@ } }, "node_modules/@discordjs/builders": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.0.tgz", - "integrity": "sha512-7pVKxVWkeLUtrTo9nTYkjRcJk0Hlms6lYervXAD7E7+K5lil9ms2JrEB1TalMiHvQMh7h1HJZ4fCJa0/vHpl4w==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", "license": "Apache-2.0", "dependencies": { "@discordjs/formatters": "^0.6.2", @@ -361,23 +360,6 @@ "dev": true, "license": "MIT" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -456,19 +438,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -493,18 +462,6 @@ "node": ">=0.1.90" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -553,15 +510,6 @@ "node": ">=4.0.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -600,24 +548,24 @@ ] }, "node_modules/discord.js": { - "version": "14.25.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", - "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "version": "14.26.3", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz", + "integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==", "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.13.0", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", - "@discordjs/rest": "^2.6.0", + "@discordjs/rest": "^2.6.1", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.33", + "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -627,9 +575,9 @@ } }, "node_modules/discord.js/node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -647,20 +595,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -670,51 +604,6 @@ "once": "^1.4.0" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -736,151 +625,18 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -938,9 +694,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.defaults": { @@ -974,36 +730,6 @@ "dev": true, "license": "ISC" }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -1091,15 +817,6 @@ "node": ">=10" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", From b670a611924a40aabeb64327b50fba4789d8ec6a Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 28 Apr 2026 14:56:55 +0900 Subject: [PATCH 3/5] =?UTF-8?q?page=20=EC=A0=84=EC=B2=B4=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=92=88=EC=A7=88/=EB=B3=B4=EC=95=88=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EB=B4=87=20RPC=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [보안/인증] - 모든 player/queue API 라우트에 세션 가드 추가 (이전: /api/servers 만 보호) - NextAuth 환경변수 부팅 시점 검증, NEXTAUTH_SECRET 명시 - next.config.ts CSP/보안 헤더 추가, 잘못된 allowedDevOrigins 제거 - Redis 호스트 하드코딩 IP 제거(필수 env 로 강제) [안정성] - 봇 RPC 패턴(@/lib/api) 공용화: crypto.randomUUID requestId, JSON.parse 안전, EXPIRE 자동, 폴링 백오프 - SSE(@/lib/sse) 공용화: subscriber error 처리, JSON.parse 안전, 30초 keep-alive, abort/에러 정리 - pause API 양 끝(boolean) 정상화: 프론트 String() 캐스트 + 백엔드 .trim().toLowerCase() 비교 제거 - 봇 RedisClient: isPaused/index/seek/volume falsy 거부 → typeof 검사로 교체(0/false 정상 허용) [타입/품질] - next-auth 모듈 보강 → session.user.id, session.accessToken 타입 안전 - DiscordServer/Track/SearchTrack 공용 타입 도입, 컴포넌트 any 제거 - BigInt permissions 안전 검증(타입 가드) - Logger: NODE_ENV 게이트, error → stderr, ISO 기반 안전 timestamp - tsconfig target → ES2020 (BigInt 리터럴) [취약점] - next 16.2.2 → 16.2.4 (DoS/postcss XSS 패치) Co-Authored-By: Claude Opus 4.7 --- bot/src/classes/RedisClient.ts | 12 +- page/next.config.ts | 38 +++- page/package-lock.json | 204 +++++-------------- page/package.json | 4 +- page/src/app/api/auth/[...nextauth]/route.ts | 36 +++- page/src/app/api/player/events/route.ts | 52 +---- page/src/app/api/player/now/route.ts | 58 +++--- page/src/app/api/player/pause/route.ts | 69 ++++--- page/src/app/api/player/play/route.ts | 63 +++--- page/src/app/api/player/playlist/route.ts | 63 +++--- page/src/app/api/player/seek/route.ts | 71 +++---- page/src/app/api/player/skip/route.ts | 58 +++--- page/src/app/api/player/volume/route.ts | 74 +++---- page/src/app/api/queue/events/route.ts | 50 +---- page/src/app/api/queue/list/route.ts | 60 +++--- page/src/app/api/queue/remove/route.ts | 73 +++---- page/src/app/api/queue/set/route.ts | 66 +++--- page/src/app/api/search/route.ts | 60 +++--- page/src/app/api/servers/route.ts | 52 +++-- page/src/app/page.tsx | 5 +- page/src/components/layout/LeftSidebar.tsx | 27 +-- page/src/components/layout/TopNav.tsx | 3 +- page/src/components/player/MainContent.tsx | 42 ++-- page/src/components/player/PlayerBar.tsx | 100 +++++---- page/src/components/player/QueueSidebar.tsx | 29 +-- page/src/lib/Logger.ts | 43 ++-- page/src/lib/Redis.ts | 29 ++- page/src/lib/api.ts | 163 +++++++++++++++ page/src/lib/sse.ts | 129 ++++++++++++ page/src/types/music.ts | 42 ++++ page/src/types/next-auth.d.ts | 21 ++ page/tsconfig.json | 2 +- 32 files changed, 1022 insertions(+), 776 deletions(-) create mode 100644 page/src/lib/api.ts create mode 100644 page/src/lib/sse.ts create mode 100644 page/src/types/music.ts create mode 100644 page/src/types/next-auth.d.ts diff --git a/bot/src/classes/RedisClient.ts b/bot/src/classes/RedisClient.ts index aeec4b9..0d29a5d 100644 --- a/bot/src/classes/RedisClient.ts +++ b/bot/src/classes/RedisClient.ts @@ -142,7 +142,8 @@ class RedisClientClass { const resultKey = `queue:remove:${data.requestId}`; if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); - if (!data.index) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index를 찾을수 없습니다." })); + // index 는 number(0 도 유효) — typeof 검증으로 변경. + if (typeof data.index !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; const numIndex = Number(data.index); @@ -159,7 +160,8 @@ class RedisClientClass { const resultKey = `player:paused:${data.requestId}`; if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); - if (!data.isPaused) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "paused를 찾을수 없습니다." })); + // isPaused 는 boolean — false 도 정상 입력. typeof 검증으로 변경. + if (typeof data.isPaused !== "boolean") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; await context.player.setPause(); @@ -178,7 +180,8 @@ class RedisClientClass { const resultKey = `player:seek:${data.requestId}`; if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); - if (!data.seek) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek를 찾을수 없습니다." })); + // seek 는 number(0 도 유효 — 처음으로 되감기) — typeof 검증으로 변경. + if (typeof data.seek !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." })); @@ -194,7 +197,8 @@ class RedisClientClass { const resultKey = `player:volume:${data.requestId}`; if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); - if (!data.volume) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume을 찾을수 없습니다." })); + // volume 은 number(0 도 유효 — 음소거) — typeof 검증으로 변경. + if (typeof data.volume !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." })); diff --git a/page/next.config.ts b/page/next.config.ts index 5505fcd..d844b5f 100644 --- a/page/next.config.ts +++ b/page/next.config.ts @@ -1,12 +1,38 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ - allowedDevOrigins: [ - "192.168.10.13", - "localhost", - "music.tkrmagid.kr" - ] + // 보안 헤더 + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + { + key: "Content-Security-Policy", + // 디스코드 CDN(이미지)과 자기 자신만 신뢰 + value: [ + "default-src 'self'", + "img-src 'self' data: https://cdn.discordapp.com https://i.scdn.co https://i.ytimg.com https://lh3.googleusercontent.com", + "script-src 'self' 'unsafe-inline'" + (process.env.NODE_ENV === "production" ? "" : " 'unsafe-eval'"), + "style-src 'self' 'unsafe-inline'", + "connect-src 'self' https://discord.com", + "font-src 'self' data:", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self' https://discord.com", + ].join("; "), + }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/page/package-lock.json b/page/package-lock.json index 2483716..92f1685 100644 --- a/page/package-lock.json +++ b/page/package-lock.json @@ -11,7 +11,7 @@ "colors": "^1.4.0", "ioredis": "^5.10.1", "lucide-react": "^1.7.0", - "next": "16.2.2", + "next": "^16.2.4", "next-auth": "^4.24.13", "react": "19.2.4", "react-dom": "19.2.4" @@ -22,7 +22,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.2", + "eslint-config-next": "^16.2.4", "tailwindcss": "^4", "typescript": "^5" } @@ -611,9 +611,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -630,9 +627,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -649,9 +643,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -668,9 +659,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -687,9 +675,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -706,9 +691,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -725,9 +707,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -744,9 +723,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -763,9 +739,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -788,9 +761,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -813,9 +783,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -838,9 +805,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -863,9 +827,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -888,9 +849,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -913,9 +871,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -938,9 +893,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1102,15 +1054,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz", - "integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.2.tgz", - "integrity": "sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz", + "integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==", "dev": true, "license": "MIT", "dependencies": { @@ -1118,9 +1070,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz", - "integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", "cpu": [ "arm64" ], @@ -1134,9 +1086,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz", - "integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", "cpu": [ "x64" ], @@ -1150,15 +1102,12 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz", - "integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1169,15 +1118,12 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz", - "integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1188,15 +1134,12 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz", - "integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1207,15 +1150,12 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz", - "integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1226,9 +1166,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz", - "integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", "cpu": [ "arm64" ], @@ -1242,9 +1182,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz", - "integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", "cpu": [ "x64" ], @@ -1463,9 +1403,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1483,9 +1420,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1503,9 +1437,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1523,9 +1454,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2076,9 +2004,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2093,9 +2018,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2110,9 +2032,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2127,9 +2046,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2144,9 +2060,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2161,9 +2074,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2178,9 +2088,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2195,9 +2102,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3270,13 +3174,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.2.tgz", - "integrity": "sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz", + "integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.2", + "@next/eslint-plugin-next": "16.2.4", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -4873,9 +4777,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4897,9 +4798,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4921,9 +4819,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4945,9 +4840,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5185,12 +5077,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz", - "integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", "dependencies": { - "@next/env": "16.2.2", + "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -5204,14 +5096,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.2", - "@next/swc-darwin-x64": "16.2.2", - "@next/swc-linux-arm64-gnu": "16.2.2", - "@next/swc-linux-arm64-musl": "16.2.2", - "@next/swc-linux-x64-gnu": "16.2.2", - "@next/swc-linux-x64-musl": "16.2.2", - "@next/swc-win32-arm64-msvc": "16.2.2", - "@next/swc-win32-x64-msvc": "16.2.2", + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { diff --git a/page/package.json b/page/package.json index 3bd2317..9d12c36 100644 --- a/page/package.json +++ b/page/package.json @@ -12,7 +12,7 @@ "colors": "^1.4.0", "ioredis": "^5.10.1", "lucide-react": "^1.7.0", - "next": "16.2.2", + "next": "^16.2.4", "next-auth": "^4.24.13", "react": "19.2.4", "react-dom": "19.2.4" @@ -23,7 +23,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.2", + "eslint-config-next": "^16.2.4", "tailwindcss": "^4", "typescript": "^5" } diff --git a/page/src/app/api/auth/[...nextauth]/route.ts b/page/src/app/api/auth/[...nextauth]/route.ts index d17448a..667f4d2 100644 --- a/page/src/app/api/auth/[...nextauth]/route.ts +++ b/page/src/app/api/auth/[...nextauth]/route.ts @@ -1,11 +1,30 @@ import NextAuth, { NextAuthOptions } from "next-auth"; import DiscordProvider from "next-auth/providers/discord"; +// 환경변수 부팅 시점 검증 — 누락 시 즉시 실패 +const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID?.trim(); +const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET?.trim(); +const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET?.trim(); + +if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET) { + throw new Error("[NextAuth] DISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET 환경변수가 설정되지 않았습니다."); +} +if (!NEXTAUTH_SECRET) { + throw new Error("[NextAuth] NEXTAUTH_SECRET 환경변수가 설정되지 않았습니다."); +} + +interface DiscordProfile { + id?: string; + username?: string; + email?: string; +} + export const authOptions: NextAuthOptions = { + secret: NEXTAUTH_SECRET, providers: [ DiscordProvider({ - clientId: process.env.DISCORD_CLIENT_ID as string, - clientSecret: process.env.DISCORD_CLIENT_SECRET as string, + clientId: DISCORD_CLIENT_ID, + clientSecret: DISCORD_CLIENT_SECRET, // 🌟 핵심: 로그인할 때 유저의 기본 정보(identify)와 서버 목록(guilds) 권한을 같이 가져옵니다! authorization: { params: { scope: "identify email guilds" } }, }), @@ -16,15 +35,16 @@ export const authOptions: NextAuthOptions = { callbacks: { // 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직 async jwt({ token, account, profile }) { - if (account && (profile as any)?.id) { - token.id = (profile as any).id; + const discordProfile = profile as DiscordProfile | undefined; + if (account && discordProfile?.id) { + token.id = discordProfile.id; token.accessToken = account.access_token; } return token; }, - async session({ session, token }: any) { - session.user.id = token.id; - session.accessToken = token.accessToken; + async session({ session, token }) { + if (token.id) session.user.id = token.id; + if (token.accessToken) session.accessToken = token.accessToken; return session; }, }, @@ -33,4 +53,4 @@ export const authOptions: NextAuthOptions = { const handler = NextAuth(authOptions); // App Router 환경에서는 GET과 POST 메서드를 둘 다 내보내야 합니다. -export { handler as GET, handler as POST }; \ No newline at end of file +export { handler as GET, handler as POST }; diff --git a/page/src/app/api/player/events/route.ts b/page/src/app/api/player/events/route.ts index b3f6c93..e0a5fb8 100644 --- a/page/src/app/api/player/events/route.ts +++ b/page/src/app/api/player/events/route.ts @@ -1,54 +1,10 @@ -// src/app/api/queue/events/route.ts +// src/app/api/player/events/route.ts import { NextRequest } from "next/server"; -import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트 +import { botEventStream } from "@/lib/sse"; // 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다. export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { - // 프론트엔드에서 보낸 serverId 가져오기 - const serverId = req.nextUrl.searchParams.get("serverId"); - - if (!serverId) { - return new Response("Missing serverId", { status: 400 }); - } - - // SSE(Server-Sent Events) 스트림 생성 - const stream = new ReadableStream({ - async start(controller) { - // 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다. - const subscriber = Redis.duplicate(); - - // 'bot-site' 채널 구독 - await subscriber.subscribe("bot-site"); - - // 메세지가 들어올 때마다 실행 - subscriber.on("message", (channel, message) => { - if (channel !== "bot-site") return; - const data = JSON.parse(message); - if (data.guildId !== serverId) return; - // 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만! - if (data.event === "player_update") { - // 프론트엔드로 "새로고침해!" 라는 데이터를 전송 - controller.enqueue(`data: ${JSON.stringify({ type: "player_update" })}\n\n`); - } - }); - - // 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리 - req.signal.addEventListener("abort", () => { - subscriber.unsubscribe("bot-site"); - subscriber.quit(); - controller.close(); - }); - } - }); - - // 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지) - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - }, - }); -} \ No newline at end of file + return botEventStream(req, { botEventName: "player_update" }); +} diff --git a/page/src/app/api/player/now/route.ts b/page/src/app/api/player/now/route.ts index a4b2893..06439ce 100644 --- a/page/src/app/api/player/now/route.ts +++ b/page/src/app/api/player/now/route.ts @@ -1,42 +1,34 @@ import { NextResponse } from "next/server"; -import { Redis } from "@/lib/Redis"; +import { Logger } from "@/lib/Logger"; +import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api"; + +interface NowBody { + serverId?: unknown; +} export async function POST(request: Request) { try { - const body = await request.json(); - const { serverId, userId } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:now:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'player_now' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_now", - requestId: requestId, - serverId: serverId, - userId: userId, - })); - - // 3. 봇의 대답 기다리기 (최대 약 3초 대기) - for (let i = 0; i < 15; i++) { - await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 - const botReply = await Redis.get(resultKey); - if (botReply) { - // 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 - await Redis.del(resultKey); - const replyData = JSON.parse(botReply); - // replyData.success 가 false면 에러 상태코드(400)로 보냄 - return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); - } - } - - // 3초가 지나도 봇이 묵묵부답일 때 - return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 }); + const { status, body } = await botRpc({ + channel: "player:now", + payload: { + action: "player_now", + serverId: serverIdResult.value, + userId, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Play API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/now API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/player/pause/route.ts b/page/src/app/api/player/pause/route.ts index b6b7023..4abd454 100644 --- a/page/src/app/api/player/pause/route.ts +++ b/page/src/app/api/player/pause/route.ts @@ -1,43 +1,46 @@ import { NextResponse } from "next/server"; -import { Redis } from "@/lib/Redis"; +import { Logger } from "@/lib/Logger"; +import { + botRpc, + errorResponse, + readJsonBody, + requireBoolean, + requireSession, + requireString, +} from "@/lib/api"; + +interface PauseBody { + serverId?: unknown; + isPaused?: unknown; +} export async function POST(request: Request) { try { - const body = await request.json(); - const { serverId, userId, isPaused } = body; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (!isPaused) return NextResponse.json({ error: "isPaused 정보가 필요합니다." }, { status: 400 }); + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:paused:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - // 봇에게 'player_pause' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_paused", - requestId: requestId, - serverId: serverId, - userId: userId, - isPaused: isPaused, - })); + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 3. 봇의 대답 기다리기 (최대 약 3초 대기) - for (let i = 0; i < 15; i++) { - await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 - const botReply = await Redis.get(resultKey); - if (botReply) { - // 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 - await Redis.del(resultKey); - const replyData = JSON.parse(botReply); - // replyData.success 가 false면 에러 상태코드(400)로 보냄 - return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); - } - } + const isPausedResult = requireBoolean(bodyResult.data.isPaused, "isPaused"); + if (!isPausedResult.ok) return isPausedResult.response; - // 3초가 지나도 봇이 묵묵부답일 때 - return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 }); + const { status, body } = await botRpc({ + channel: "player:paused", + payload: { + action: "player_paused", + serverId: serverIdResult.value, + userId, + isPaused: isPausedResult.value, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Play API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/pause API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/player/play/route.ts b/page/src/app/api/player/play/route.ts index d64eda5..e92f68b 100644 --- a/page/src/app/api/player/play/route.ts +++ b/page/src/app/api/player/play/route.ts @@ -1,44 +1,39 @@ import { NextResponse } from "next/server"; -import { Redis } from "@/lib/Redis"; +import { Logger } from "@/lib/Logger"; +import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api"; + +interface PlayBody { + serverId?: unknown; + track?: unknown; +} export async function POST(request: Request) { try { - const body = await request.json(); - const { serverId, userId, track } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (!track) return NextResponse.json({ error: "track 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:play:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'player_play' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_play", - requestId: requestId, - serverId: serverId, - userId: userId, - track: track, - })); + const track = bodyResult.data.track; + if (!track || typeof track !== "object") return errorResponse("track 정보가 필요합니다."); - // 3. 봇의 대답 기다리기 (최대 약 3초 대기) - for (let i = 0; i < 15; i++) { - await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 - const botReply = await Redis.get(resultKey); - if (botReply) { - // 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 - await Redis.del(resultKey); - const replyData = JSON.parse(botReply); - // replyData.success 가 false면 에러 상태코드(400)로 보냄 - return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); - } - } - - // 3초가 지나도 봇이 묵묵부답일 때 - return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 }); + const { status, body } = await botRpc({ + channel: "player:play", + payload: { + action: "player_play", + serverId: serverIdResult.value, + userId, + track, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Play API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/play API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/player/playlist/route.ts b/page/src/app/api/player/playlist/route.ts index 5acbe1f..f3f5a3b 100644 --- a/page/src/app/api/player/playlist/route.ts +++ b/page/src/app/api/player/playlist/route.ts @@ -1,44 +1,39 @@ import { NextResponse } from "next/server"; -import { Redis } from "@/lib/Redis"; +import { Logger } from "@/lib/Logger"; +import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api"; + +interface PlaylistBody { + serverId?: unknown; + playlistUrl?: unknown; +} export async function POST(request: Request) { try { - const body = await request.json(); - const { serverId, userId, playlistUrl } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (!playlistUrl) return NextResponse.json({ error: "playlistUrl 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:playlist:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'player_playlist' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_playlist", - requestId: requestId, - serverId: serverId, - userId: userId, - playlistUrl: playlistUrl, - })); + const urlResult = requireString(bodyResult.data.playlistUrl, "playlistUrl"); + if (!urlResult.ok) return urlResult.response; - // 3. 봇의 대답 기다리기 (최대 약 3초 대기) - for (let i = 0; i < 15; i++) { - await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 - const botReply = await Redis.get(resultKey); - if (botReply) { - // 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 - await Redis.del(resultKey); - const replyData = JSON.parse(botReply); - // replyData.success 가 false면 에러 상태코드(400)로 보냄 - return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); - } - } - - // 3초가 지나도 봇이 묵묵부답일 때 - return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 }); + const { status, body } = await botRpc({ + channel: "player:playlist", + payload: { + action: "player_playlist", + serverId: serverIdResult.value, + userId, + playlistUrl: urlResult.value, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Queue Adds API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/playlist API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/player/seek/route.ts b/page/src/app/api/player/seek/route.ts index 7f7116e..fdf3159 100644 --- a/page/src/app/api/player/seek/route.ts +++ b/page/src/app/api/player/seek/route.ts @@ -1,44 +1,47 @@ import { NextResponse } from "next/server"; -import { Redis } from "@/lib/Redis"; +import { Logger } from "@/lib/Logger"; +import { + botRpc, + errorResponse, + readJsonBody, + requireNumber, + requireSession, + requireString, +} from "@/lib/api"; + +interface SeekBody { + serverId?: unknown; + seek?: unknown; +} export async function POST(request: Request) { try { - const body = await request.json(); - const { serverId, userId, seek } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (!seek) return NextResponse.json({ error: "seek 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:seek:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'player_seek' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_seek", - requestId: requestId, - serverId: serverId, - userId: userId, - seek: seek, - })); + // seek 는 0(처음으로 되감기) 도 정상 입력. requireNumber 는 0 허용. + const seekResult = requireNumber(bodyResult.data.seek, "seek", { min: 0, integer: true }); + if (!seekResult.ok) return seekResult.response; - // 3. 봇의 대답 기다리기 (최대 약 3초 대기) - for (let i = 0; i < 15; i++) { - await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 - const botReply = await Redis.get(resultKey); - if (botReply) { - // 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 - await Redis.del(resultKey); - const replyData = JSON.parse(botReply); - // replyData.success 가 false면 에러 상태코드(400)로 보냄 - return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); - } - } - - // 3초가 지나도 봇이 묵묵부답일 때 - return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 }); + const { status, body } = await botRpc({ + channel: "player:seek", + payload: { + action: "player_seek", + serverId: serverIdResult.value, + userId, + seek: seekResult.value, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Play API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/seek API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/player/skip/route.ts b/page/src/app/api/player/skip/route.ts index 25bdee1..d2d15b3 100644 --- a/page/src/app/api/player/skip/route.ts +++ b/page/src/app/api/player/skip/route.ts @@ -1,42 +1,34 @@ import { NextResponse } from "next/server"; -import { Redis } from "@/lib/Redis"; +import { Logger } from "@/lib/Logger"; +import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api"; + +interface SkipBody { + serverId?: unknown; +} export async function POST(request: Request) { try { - const body = await request.json(); - const { serverId, userId } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:skip:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'player_skip' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_skip", - requestId: requestId, - serverId: serverId, - userId: userId, - })); - - // 3. 봇의 대답 기다리기 (최대 약 3초 대기) - for (let i = 0; i < 15; i++) { - await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 - const botReply = await Redis.get(resultKey); - if (botReply) { - // 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 - await Redis.del(resultKey); - const replyData = JSON.parse(botReply); - // replyData.success 가 false면 에러 상태코드(400)로 보냄 - return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); - } - } - - // 3초가 지나도 봇이 묵묵부답일 때 - return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 }); + const { status, body } = await botRpc({ + channel: "player:skip", + payload: { + action: "player_skip", + serverId: serverIdResult.value, + userId, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Play API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/skip API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/player/volume/route.ts b/page/src/app/api/player/volume/route.ts index 6d70f15..9360567 100644 --- a/page/src/app/api/player/volume/route.ts +++ b/page/src/app/api/player/volume/route.ts @@ -1,44 +1,50 @@ import { NextResponse } from "next/server"; -import { Redis } from "@/lib/Redis"; +import { Logger } from "@/lib/Logger"; +import { + botRpc, + errorResponse, + readJsonBody, + requireNumber, + requireSession, + requireString, +} from "@/lib/api"; + +interface VolumeBody { + serverId?: unknown; + volume?: unknown; +} export async function POST(request: Request) { try { - const body = await request.json(); - const { serverId, userId, volume } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (!volume) return NextResponse.json({ error: "volume 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `player:volume:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'player_volume' 명령 전송 - await Redis.publish("site-bot", JSON.stringify({ - action: "player_volume", - requestId: requestId, - serverId: serverId, - userId: userId, - volume: volume, - })); + const volumeResult = requireNumber(bodyResult.data.volume, "volume", { + min: 0, + max: 100, + integer: true, + }); + if (!volumeResult.ok) return volumeResult.response; - // 3. 봇의 대답 기다리기 (최대 약 3초 대기) - for (let i = 0; i < 15; i++) { - await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 - const botReply = await Redis.get(resultKey); - if (botReply) { - // 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 - await Redis.del(resultKey); - const replyData = JSON.parse(botReply); - // replyData.success 가 false면 에러 상태코드(400)로 보냄 - return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); - } - } - - // 3초가 지나도 봇이 묵묵부답일 때 - return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 }); + const { status, body } = await botRpc({ + channel: "player:volume", + payload: { + action: "player_volume", + serverId: serverIdResult.value, + userId, + volume: volumeResult.value, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Play API Error:", error); - return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`player/volume API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/queue/events/route.ts b/page/src/app/api/queue/events/route.ts index 0a374c6..927333b 100644 --- a/page/src/app/api/queue/events/route.ts +++ b/page/src/app/api/queue/events/route.ts @@ -1,54 +1,10 @@ // src/app/api/queue/events/route.ts import { NextRequest } from "next/server"; -import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트 +import { botEventStream } from "@/lib/sse"; // 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다. export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { - // 프론트엔드에서 보낸 serverId 가져오기 - const serverId = req.nextUrl.searchParams.get("serverId"); - - if (!serverId) { - return new Response("Missing serverId", { status: 400 }); - } - - // SSE(Server-Sent Events) 스트림 생성 - const stream = new ReadableStream({ - async start(controller) { - // 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다. - const subscriber = Redis.duplicate(); - - // 'bot-site' 채널 구독 - await subscriber.subscribe("bot-site"); - - // 메세지가 들어올 때마다 실행 - subscriber.on("message", (channel, message) => { - if (channel !== "bot-site") return; - const data = JSON.parse(message); - if (data.guildId !== serverId) return; - // 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만! - if (data.event === "queue_update") { - // 프론트엔드로 "새로고침해!" 라는 데이터를 전송 - controller.enqueue(`data: ${JSON.stringify({ type: "queue_update" })}\n\n`); - } - }); - - // 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리 - req.signal.addEventListener("abort", () => { - subscriber.unsubscribe("bot-site"); - subscriber.quit(); - controller.close(); - }); - } - }); - - // 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지) - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - }, - }); -} \ No newline at end of file + return botEventStream(req, { botEventName: "queue_update" }); +} diff --git a/page/src/app/api/queue/list/route.ts b/page/src/app/api/queue/list/route.ts index 8fb6667..b166c3a 100644 --- a/page/src/app/api/queue/list/route.ts +++ b/page/src/app/api/queue/list/route.ts @@ -1,44 +1,34 @@ import { NextResponse } from "next/server"; -import { Redis } from "@/lib/Redis"; +import { Logger } from "@/lib/Logger"; +import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api"; + +interface QueueListBody { + serverId?: unknown; +} export async function POST(request: Request) { try { - const body = await request.json(); - const { serverId, userId } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - // 1. 고유한 요청 ID(진동벨) 생성 - const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `queue:list:${requestId}`; - - // 2. 봇에게 'queue_list' 명령 발송 - await Redis.publish("site-bot", JSON.stringify({ - action: "queue_list", - serverId: serverId, - userId: userId, - requestId: requestId, // 🌟 봇이 대답을 남길 키 - })); - - // 3. 봇의 대답 기다리기 (최대 약 3초 대기) - for (let i = 0; i < 15; i++) { - await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 - const botReply = await Redis.get(resultKey); - if (botReply) { - // 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 - await Redis.del(resultKey); - const replyData = JSON.parse(botReply); - // replyData.success 가 false면 에러 상태코드(400)로 보냄 - return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); - } - } - - // 3초가 지나도 봇이 묵묵부답일 때 - return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 }); + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; + const { status, body } = await botRpc({ + channel: "queue:list", + payload: { + action: "queue_list", + serverId: serverIdResult.value, + userId, + }, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Queue List API Error:", error); - return NextResponse.json({ success: false, message: "서버 오류가 발생했습니다." }, { status: 500 }); + Logger.error(`queue/list API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/queue/remove/route.ts b/page/src/app/api/queue/remove/route.ts index c91a2a6..523a10b 100644 --- a/page/src/app/api/queue/remove/route.ts +++ b/page/src/app/api/queue/remove/route.ts @@ -1,45 +1,48 @@ import { NextResponse } from "next/server"; -import { Redis } from "@/lib/Redis"; +import { Logger } from "@/lib/Logger"; +import { + botRpc, + errorResponse, + readJsonBody, + requireNumber, + requireSession, + requireString, +} from "@/lib/api"; + +interface QueueRemoveBody { + serverId?: unknown; + index?: unknown; +} export async function POST(request: Request) { try { - const body = await request.json(); - const { serverId, index, userId } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (!index) return NextResponse.json({ error: "index 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `queue:remove:${requestId}`; + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'remove_queue' 명령 발송 (몇 번째 인덱스를 지워라) - await Redis.publish("site-bot", JSON.stringify({ - action: "queue_remove", - serverId: serverId, - requestId: requestId, - userId: userId, - index: index, - })); + // index 0 도 정상값 + const indexResult = requireNumber(bodyResult.data.index, "index", { min: 0, integer: true }); + if (!indexResult.ok) return indexResult.response; - // 4. 결과가 올라올 때까지 기다리기 (Polling) - // 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다. - for (let i = 0; i < 10; i++) { - // 0.5초 대기 - await new Promise(resolve => setTimeout(resolve, 500)); - - // Redis 게시판 확인 - const resultData = await Redis.get(resultKey); - - if (resultData) { - // 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료 - return NextResponse.json(JSON.parse(resultData)); - } - } - - // 5초가 지나도 응답이 없으면 타임아웃 - return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 }); + const { status, body } = await botRpc({ + channel: "queue:remove", + payload: { + action: "queue_remove", + serverId: serverIdResult.value, + userId, + index: indexResult.value, + }, + timeoutMs: 5000, + }); + return NextResponse.json(body, { status }); } catch (error) { - return NextResponse.json({ error: "서버 오류" }, { status: 500 }); + Logger.error(`queue/remove API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/queue/set/route.ts b/page/src/app/api/queue/set/route.ts index c517dba..7469957 100644 --- a/page/src/app/api/queue/set/route.ts +++ b/page/src/app/api/queue/set/route.ts @@ -1,46 +1,40 @@ import { NextResponse } from "next/server"; -import { Redis } from "@/lib/Redis"; +import { Logger } from "@/lib/Logger"; +import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api"; + +interface QueueSetBody { + serverId?: unknown; + newQueue?: unknown; +} export async function POST(request: Request) { try { - const body = await request.json(); - const { serverId, newQueue, userId } = body; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; + const userId = sessionResult.session.user.id; - if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); - if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); - if (newQueue === undefined || newQueue === null) return NextResponse.json({ error: "newQueue 정보가 필요합니다." }, { status: 400 }); + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) return bodyResult.response; - const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `queue:set:${requestId}`; + const serverIdResult = requireString(bodyResult.data.serverId, "serverId"); + if (!serverIdResult.ok) return serverIdResult.response; - // 봇에게 'queue_set' 명령 발송 (전체 대기열을 통째로 덮어써라!) - await Redis.publish("site-bot", JSON.stringify({ - action: "queue_set", - serverId: serverId, - requestId: requestId, - userId: userId, - newQueue: newQueue, - })); + const newQueue = bodyResult.data.newQueue; + if (!Array.isArray(newQueue)) return errorResponse("newQueue 정보가 필요합니다."); - // 4. 결과가 올라올 때까지 기다리기 (Polling) - // 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다. - for (let i = 0; i < 10; i++) { - // 0.5초 대기 - await new Promise(resolve => setTimeout(resolve, 500)); - - // Redis 게시판 확인 - const resultData = await Redis.get(resultKey); - - if (resultData) { - // 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료 - return NextResponse.json(JSON.parse(resultData)); - } - } - - // 5초가 지나도 응답이 없으면 타임아웃 - return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 }); + const { status, body } = await botRpc({ + channel: "queue:set", + payload: { + action: "queue_set", + serverId: serverIdResult.value, + userId, + newQueue, + }, + timeoutMs: 5000, + }); + return NextResponse.json(body, { status }); } catch (error) { - console.error("Queue Reorder API Error:", error); - return NextResponse.json({ error: "서버 오류" }, { status: 500 }); + Logger.error(`queue/set API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } -} \ No newline at end of file +} diff --git a/page/src/app/api/search/route.ts b/page/src/app/api/search/route.ts index 7793e07..956db0d 100644 --- a/page/src/app/api/search/route.ts +++ b/page/src/app/api/search/route.ts @@ -1,42 +1,30 @@ // src/app/api/search/route.ts import { NextResponse } from "next/server"; -import { Redis } from "@/lib/Redis"; +import { Logger } from "@/lib/Logger"; +import { botRpc, errorResponse, requireSession } from "@/lib/api"; export async function GET(request: Request) { - // 1. 검색어(query) 가져오기 - const { searchParams } = new URL(request.url); - const query = searchParams.get("q"); + try { + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; - if (!query) { - return NextResponse.json({ error: "검색어가 없습니다." }, { status: 400 }); + const { searchParams } = new URL(request.url); + const query = searchParams.get("q")?.trim(); + + if (!query) return errorResponse("검색어가 없습니다.", 400); + + const { status, body } = await botRpc({ + channel: "search", + payload: { + action: "search", + query, + }, + timeoutMs: 10000, + pollIntervalMs: 250, + }); + return NextResponse.json(body, { status }); + } catch (error) { + Logger.error(`search API error: ${error instanceof Error ? error.message : String(error)}`); + return errorResponse("서버 오류가 발생했습니다.", 500); } - - // 2. 고유한 요청 ID 생성 (예: 1690001234567-abc) - const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `search:${requestId}`; - - // 3. 봇에게 'site-bot' 채널로 검색 명령 발송 (Publish) - await Redis.publish("site-bot", JSON.stringify({ - action: "search", - query: query, - requestId: requestId, - })); - - // 4. 결과가 올라올 때까지 기다리기 (Polling) - // 최대 10번(약 10초) 동안 1.0초 간격으로 확인합니다. - for (let i=0; i<10; i++) { - // 1.0초 대기 - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Redis 게시판 확인 - const resultData = await Redis.get(resultKey); - - if (resultData) { - // 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료 - return NextResponse.json(JSON.parse(resultData)); - } - } - - // 5초가 지나도 응답이 없으면 타임아웃 - return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 }); -} \ No newline at end of file +} diff --git a/page/src/app/api/servers/route.ts b/page/src/app/api/servers/route.ts index 2a3892c..e544c3a 100644 --- a/page/src/app/api/servers/route.ts +++ b/page/src/app/api/servers/route.ts @@ -1,34 +1,58 @@ import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; import { Redis } from "@/lib/Redis"; -import { authOptions } from "../auth/[...nextauth]/route"; +import { Logger } from "@/lib/Logger"; +import { requireSession } from "@/lib/api"; + +interface DiscordGuild { + id: string; + name: string; + icon: string | null; + owner: boolean; + permissions: string; +} export async function GET() { - const session = await getServerSession(authOptions) as any; + const sessionResult = await requireSession(); + if (!sessionResult.ok) return sessionResult.response; - if (!session || !session.accessToken) { - return NextResponse.json({ error: "인증되지 않았습니다." }, { status: 401 }); + const accessToken = sessionResult.session.accessToken; + if (!accessToken) { + return NextResponse.json({ success: false, error: "Discord 액세스 토큰이 없습니다." }, { status: 401 }); } try { // 1. 디스코드 API에서 유저가 속한 서버 목록 가져오기 const userGuildsRes = await fetch("https://discord.com/api/users/@me/guilds", { - headers: { Authorization: `Bearer ${session.accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, }); - const userGuilds = await userGuildsRes.json() ?? []; + if (!userGuildsRes.ok) { + Logger.warn(`Discord guilds API ${userGuildsRes.status} ${userGuildsRes.statusText}`); + return NextResponse.json( + { success: false, error: "Discord 서버 목록을 가져오지 못했습니다." }, + { status: 502 }, + ); + } + const userGuildsRaw: unknown = await userGuildsRes.json(); + const userGuilds: DiscordGuild[] = Array.isArray(userGuildsRaw) ? (userGuildsRaw as DiscordGuild[]) : []; // 2. Redis에서 봇이 속한 서버 목록(화이트리스트) 가져오기 const botGuildsData = await Redis.get("bot-guilds"); - const botGuildIds: string[] = botGuildsData ? JSON.parse(botGuildsData) : []; + let botGuildIds: string[] = []; + if (botGuildsData) { + try { + const parsed = JSON.parse(botGuildsData); + if (Array.isArray(parsed)) botGuildIds = parsed.filter((v): v is string => typeof v === "string"); + } catch { + Logger.warn("Redis bot-guilds JSON 파싱 실패"); + } + } // 3. 🌟 두 목록을 비교해서 봇이 있는 서버만 필터링! - const filteredGuilds = userGuilds.filter((guild: any) => - botGuildIds.includes(guild.id) - ); + const filteredGuilds = userGuilds.filter((guild) => botGuildIds.includes(guild.id)); return NextResponse.json(filteredGuilds); } catch (error) { - console.error("서버 필터링 에러:", error); - return NextResponse.json({ error: "서버 목록을 가져오지 못했습니다." }, { status: 500 }); + Logger.error(`서버 필터링 에러: ${error instanceof Error ? error.message : String(error)}`); + return NextResponse.json({ success: false, error: "서버 목록을 가져오지 못했습니다." }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/page/src/app/page.tsx b/page/src/app/page.tsx index d4eaff0..201b0e4 100644 --- a/page/src/app/page.tsx +++ b/page/src/app/page.tsx @@ -6,13 +6,14 @@ import LeftSidebar from "@/components/layout/LeftSidebar"; import MainContent from "@/components/player/MainContent"; import QueueSidebar from "@/components/player/QueueSidebar"; import PlayerBar from "@/components/player/PlayerBar"; +import type { DiscordServer } from "@/types/music"; // 화면 모드 타입 정의 export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT"; export default function MusicPlayerLayout() { const [viewMode, setViewMode] = useState("SERVER_LIST"); - const [selectedServer, setSelectedServer] = useState(null); + const [selectedServer, setSelectedServer] = useState(null); const [searchQuery, setSearchQuery] = useState(""); // 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀 @@ -33,7 +34,7 @@ export default function MusicPlayerLayout() { }; // 서버 선택 시 - const handleSelectServer = (server: any) => { + const handleSelectServer = (server: DiscordServer) => { setSelectedServer(server); setViewMode("SERVER_DETAIL"); }; diff --git a/page/src/components/layout/LeftSidebar.tsx b/page/src/components/layout/LeftSidebar.tsx index 011171c..6d2703b 100644 --- a/page/src/components/layout/LeftSidebar.tsx +++ b/page/src/components/layout/LeftSidebar.tsx @@ -1,25 +1,20 @@ "use client"; -import { ListMusic, Library } from "lucide-react"; +import { ListMusic } from "lucide-react"; export default function LeftSidebar() { return ( ); -} \ No newline at end of file +} diff --git a/page/src/components/layout/TopNav.tsx b/page/src/components/layout/TopNav.tsx index 179a3bf..c0f14ad 100644 --- a/page/src/components/layout/TopNav.tsx +++ b/page/src/components/layout/TopNav.tsx @@ -2,11 +2,12 @@ import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react"; import { signIn, signOut, useSession } from "next-auth/react"; import { useState } from "react"; +import type { DiscordServer } from "@/types/music"; interface TopNavProps { onSearch: (query: string) => void; onHome: () => void; - selectedServer: any; // 🌟 추가: 선택된 서버 정보 + selectedServer: DiscordServer | null; // 🌟 추가: 선택된 서버 정보 } export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) { diff --git a/page/src/components/player/MainContent.tsx b/page/src/components/player/MainContent.tsx index cebb1b3..e1440a3 100644 --- a/page/src/components/player/MainContent.tsx +++ b/page/src/components/player/MainContent.tsx @@ -5,21 +5,16 @@ import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc } import { ViewMode } from "@/app/page"; // 🌟 [추가됨] 전역 토스트 훅 불러오기 import { useToast } from "@/components/ToastProvider"; +import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music"; interface MainContentProps { viewMode: ViewMode; setViewMode: (mode: ViewMode) => void; - selectedServer: any; - setSelectedServer: (server: any) => void; + selectedServer: DiscordServer | null; + setSelectedServer: (server: DiscordServer | null) => void; searchQuery: string; setSearchQuery: (query: string) => void; - onSelectServer: (server: any) => void; -} - -interface SearchResultsType { - spotify: any[]; - youtubeMusic: any[]; - youtubeVideo: any[]; + onSelectServer: (server: DiscordServer) => void; } export default function MainContent({ @@ -36,27 +31,31 @@ export default function MainContent({ // 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기 const { showToast } = useToast(); - const [servers, setServers] = useState([]); + const [servers, setServers] = useState([]); const [isFetching, setIsFetching] = useState(false); - const [searchResults, setSearchResults] = useState({ + const [searchResults, setSearchResults] = useState({ spotify: [], youtubeMusic: [], youtubeVideo: [] }); const [isSearching, setIsSearching] = useState(false); - const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: any, playlistUrl?: string) => { + const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => { if (!selectedServer) { // 🌟 alert -> showToast 교체 showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error"); return; } - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; + if (!userId) { + showToast("로그인이 필요합니다.", "error"); + return; + } let endpoint = ""; - let bodyData: any = { serverId: selectedServer.id, userId: userId }; + const bodyData: Record = { serverId: selectedServer.id, userId }; if (actionType === 'player_play') { endpoint = "/api/player/play"; bodyData.track = track; @@ -88,13 +87,16 @@ export default function MainContent({ } }; - const getPermissionLabel = (server: any) => { + const getPermissionLabel = (server: DiscordServer | null) => { if (!server) return "알 수 없음"; if (server.owner) return "👑 서버 주인"; + // Discord permissions 는 큰 정수 문자열로 도착함. 숫자/문자열만 받아 안전하게 BigInt 화. + const raw: unknown = server.permissions; + if (typeof raw !== "string" && typeof raw !== "number") return "👤 일반 멤버"; try { - const perms = BigInt(server.permissions); - if ((perms & BigInt(0x8)) === BigInt(0x8)) return "🛠️ 관리자"; - if ((perms & BigInt(0x20)) === BigInt(0x20)) return "⚙️ 매니저"; + const perms = BigInt(raw); + if ((perms & 0x8n) === 0x8n) return "🛠️ 관리자"; + if ((perms & 0x20n) === 0x20n) return "⚙️ 매니저"; return "👤 일반 멤버"; } catch { return "👤 일반 멤버"; @@ -159,7 +161,7 @@ export default function MainContent({ const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0; - const renderTrackCard = (track: any) => ( + const renderTrackCard = (track: SearchTrack) => (
{track.thumbnail && {track.title}} @@ -258,7 +260,7 @@ export default function MainContent({ {/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */} {viewMode === "SEARCH_RESULT" && (
-

"{searchQuery}" 검색 결과

+

“{searchQuery}” 검색 결과

입력하신 검색어에 대한 플랫폼별 결과입니다.

{isSearching ? ( diff --git a/page/src/components/player/PlayerBar.tsx b/page/src/components/player/PlayerBar.tsx index 0457646..4e676c6 100644 --- a/page/src/components/player/PlayerBar.tsx +++ b/page/src/components/player/PlayerBar.tsx @@ -2,16 +2,17 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react"; import { useSession } from "next-auth/react"; +import type { DiscordServer, Track } from "@/types/music"; interface PlayerBarProps { - selectedServer: any; + selectedServer: DiscordServer | null; } export default function PlayerBar({ selectedServer }: PlayerBarProps) { const { data: session } = useSession(); // 재생 상태 관리 - const [track, setTrack] = useState(null); + const [track, setTrack] = useState(null); const [botPlayer, setBotPlayer] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [isPaused, setIsPaused] = useState(false); @@ -28,7 +29,7 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { const fetchNowPlaying = useCallback(async () => { if (!selectedServer) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; if (!userId) return; try { @@ -43,12 +44,12 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { const data = await res.json(); if (res.ok && data.success && data.track) { - setBotPlayer(data.botPlayer); - setIsPlaying(data.isPlaying); - setIsPaused(data.isPaused); - setTrack(data.track); - setDuration(data.track.info.length || 0); - setVolume(data.volume ?? 50); + setBotPlayer(Boolean(data.botPlayer)); + setIsPlaying(Boolean(data.isPlaying)); + setIsPaused(Boolean(data.isPaused)); + setTrack(data.track as Track); + setDuration(Number(data.track?.info?.length ?? 0) || 0); + setVolume(typeof data.volume === "number" ? data.volume : 50); // 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐) if (!isDragging.current) { setPosition(data.position || 0); @@ -67,41 +68,53 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { // 2. 초기 로드 및 SSE 실시간 업데이트 수신 useEffect(() => { if (!selectedServer) return; + // 서버 선택 시 1회 즉시 동기화 — 의도적 패턴. + // eslint-disable-next-line react-hooks/set-state-in-effect fetchNowPlaying(); // 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호 const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`); eventSource.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data.type === "player_update") { - fetchNowPlaying(); + try { + const data = JSON.parse(event.data); + if (data?.type === "player_update") { + fetchNowPlaying(); + } + } catch (err) { + console.warn("SSE JSON 파싱 실패:", err); } }; + eventSource.onerror = (error) => { + console.error("Player SSE 연결 오류:", error); + eventSource.close(); + }; return () => eventSource.close(); }, [selectedServer, fetchNowPlaying]); // 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!) + // isPaused 상태를 ref 로 들고 있어서, interval 콜백이 항상 최신 값을 읽도록 처리. + const isPausedRef = useRef(isPaused); useEffect(() => { - let localInterval: NodeJS.Timeout; - let syncInterval: NodeJS.Timeout; + isPausedRef.current = isPaused; + }, [isPaused]); - // 노래가 재생 중이고, 유저가 재생바를 잡고 있지 않을 때만 타이머들을 가동합니다. - if (isPlaying && !isDragging.current) { + useEffect(() => { + if (!isPlaying) return; - // ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용) - localInterval = setInterval(() => { - if (!isPaused) setPosition((prev) => { - if (prev >= duration) return duration; - return prev + 1000; - }); - }, 1000); + // ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용) + const localInterval = setInterval(() => { + if (isPausedRef.current || isDragging.current) return; + setPosition((prev) => { + if (prev >= duration) return duration; + return prev + 1000; + }); + }, 1000); - // ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용) - syncInterval = setInterval(() => { - if (!isPaused) fetchNowPlaying(); - }, 10000); - - } + // ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용) + const syncInterval = setInterval(() => { + if (isPausedRef.current || isDragging.current) return; + fetchNowPlaying(); + }, 10000); // 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다. return () => { @@ -117,9 +130,11 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { const handleTogglePause = async () => { if (!selectedServer || !track) return; if (!isPlaying) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; + if (!userId) return; + const nextPaused = !isPaused; // UI 즉각 반영 (Optimistic UI) - setIsPaused(!isPaused); + setIsPaused(nextPaused); try { const res = await fetch('/api/player/pause', { method: 'POST', @@ -127,27 +142,28 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { body: JSON.stringify({ serverId: selectedServer.id, userId: userId, - isPaused: String(isPaused), + isPaused: nextPaused, // boolean 그대로 전송 }) }); const data = await res.json(); if (res.ok && data.success) { - if (data.isPaused?.trim().toLocaleLowerCase() === "true") { - setIsPaused(true); - } else { - setIsPaused(false); - } + // 봇이 실제로 적용된 paused 상태를 돌려줌. 없으면 낙관적 값 유지. + if (typeof data.paused === "boolean") setIsPaused(data.paused); + } else { + // 실패 시 롤백 + setIsPaused(!nextPaused); } } catch (error) { console.error("일시정지 에러:", error); - setIsPaused(!isPaused); // 실패 시 롤백 + setIsPaused(!nextPaused); // 실패 시 롤백 } }; // 다음 곡 스킵 (player_skip) const handleSkip = async () => { if (!selectedServer || !track) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; + if (!userId) return; try { await fetch('/api/player/skip', { method: 'POST', @@ -167,7 +183,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { // 🌟 [수정됨] 마우스 이벤트와 터치 이벤트를 모두 허용하도록 타입 변경 const handleSeekEnd = async (e: React.MouseEvent | React.TouchEvent) => { if (!selectedServer || !track) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; + if (!userId) return; isDragging.current = false; // 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다. @@ -198,7 +215,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) { const handleVolumeEnd = async (e: React.MouseEvent | React.TouchEvent) => { setIsVolumeDragging(false); if (!selectedServer) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; + if (!userId) return; const finalVolume = Number(e.currentTarget.value); setVolume(finalVolume); // UI 즉시 반영 diff --git a/page/src/components/player/QueueSidebar.tsx b/page/src/components/player/QueueSidebar.tsx index 9d5fde9..02e4ce0 100644 --- a/page/src/components/player/QueueSidebar.tsx +++ b/page/src/components/player/QueueSidebar.tsx @@ -3,9 +3,10 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { Trash2, GripVertical, Music } from "lucide-react"; import { useSession } from "next-auth/react"; import { useToast } from "@/components/ToastProvider"; +import type { DiscordServer, Track } from "@/types/music"; interface QueueSidebarProps { - selectedServer: any; + selectedServer: DiscordServer | null; } export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { @@ -14,8 +15,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { // 👇 [추가] 토스트 사용 준비 완료! const { showToast } = useToast(); - const [queue, setQueue] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [queue, setQueue] = useState([]); const [draggingIndex, setDraggingIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); @@ -26,10 +26,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { const fetchQueue = useCallback(async () => { if (!selectedServer) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; if (!userId) return; - setIsLoading(true); try { const res = await fetch('/api/queue/list', { method: 'POST', @@ -41,29 +40,33 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { }); const data = await res.json(); if (res.ok && data.success && Array.isArray(data.queue)) { - setQueue(data.queue); + setQueue(data.queue as Track[]); } else { setQueue([]); } } catch (error) { console.error("큐 불러오기 실패:", error); setQueue([]); - } finally { - setIsLoading(false); } }, [selectedServer, session]); useEffect(() => { if (status === "loading" || !selectedServer) return; + // 서버 선택 시 1회 즉시 동기화 — 의도적 패턴. + // eslint-disable-next-line react-hooks/set-state-in-effect fetchQueue(); const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`); eventSource.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data.type === "queue_update") { - fetchQueue(); + try { + const data = JSON.parse(event.data); + if (data?.type === "queue_update") { + fetchQueue(); + } + } catch (err) { + console.warn("SSE JSON 파싱 실패:", err); } }; @@ -106,7 +109,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { } // --- 여기서부터는 진짜 순서가 바뀌었을 때만 실행됩니다 --- - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; // 3. 화면 즉시 업데이트 const newQueue = [...queue]; @@ -139,7 +142,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { const handleDelete = async (indexToRemove: number) => { if (!selectedServer) return; - const userId = (session?.user as any)?.id; + const userId = session?.user?.id; const newQueue = queue.filter((_, index) => index !== indexToRemove); setQueue(newQueue); diff --git a/page/src/lib/Logger.ts b/page/src/lib/Logger.ts index 9d4ace1..857a197 100644 --- a/page/src/lib/Logger.ts +++ b/page/src/lib/Logger.ts @@ -1,28 +1,43 @@ import colors from "colors/safe"; +// Asia/Seoul(UTC+9) 타임스탬프. ISO 포맷에서 안전하게 추출. export const Timestamp = () => { - const Now = new Date(); - Now.setHours(Now.getHours() + 9); - return Now.toISOString().replace('T', ' ').substring(0, 19).slice(2); -} + const now = new Date(Date.now() + 9 * 60 * 60 * 1000); + // YYYY-MM-DDTHH:mm:ss.sssZ 에서 YY-MM-DD HH:mm:ss + const iso = now.toISOString(); + const date = iso.slice(2, 10); // YY-MM-DD + const time = iso.slice(11, 19); // HH:mm:ss + return `${date} ${time}`; +}; -type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash"; +type LogType = "log" | "info" | "warn" | "error" | "debug" | "ready"; -const log = (content: string, type: logType) => { +const isProd = process.env.NODE_ENV === "production"; + +const write = (label: string, content: string, useStderr: boolean) => { const timestamp = colors.white(`[${Timestamp()}]`); + const line = `${label} ${timestamp} ${content}`; + if (useStderr) console.error(line); + else console.log(line); +}; + +const log = (content: string, type: LogType) => { switch (type) { case "log": - return console.log(`${colors.gray("[LOG]")} ${timestamp} ${content}`); + // 일반 디버그성 로그는 프로덕션에서 숨김 + if (isProd) return; + return write(colors.gray("[LOG]"), content, false); case "info": - return console.log(`${colors.cyan("[INFO]")} ${timestamp} ${content}`); + return write(colors.cyan("[INFO]"), content, false); case "warn": - return console.log(`${colors.yellow("[WARN]")} ${timestamp} ${content}`); + return write(colors.yellow("[WARN]"), content, true); case "error": - return console.log(`${colors.red("[ERROR]")} ${timestamp} ${content}`); + return write(colors.red("[ERROR]"), content, true); case "debug": - return console.log(`${colors.magenta("[DEBUG]")} ${timestamp} ${content}`); + if (isProd) return; + return write(colors.magenta("[DEBUG]"), content, false); case "ready": - return console.log(`${colors.green("[READY]")} ${timestamp} ${content}`); + return write(colors.green("[READY]"), content, false); default: throw new TypeError("Logger 타입이 올바르지 않습니다."); } @@ -34,5 +49,5 @@ export const Logger = { error: (content: string) => log(content, "error"), debug: (content: string) => log(content, "debug"), info: (content: string) => log(content, "info"), - ready: (content: string) => log(content, "ready") -} \ No newline at end of file + ready: (content: string) => log(content, "ready"), +}; diff --git a/page/src/lib/Redis.ts b/page/src/lib/Redis.ts index 27cad17..d2fc3fe 100644 --- a/page/src/lib/Redis.ts +++ b/page/src/lib/Redis.ts @@ -1,7 +1,16 @@ import { Redis as RedisClass } from "ioredis"; +import { Logger } from "@/lib/Logger"; -// .env.local 파일에서 설정한 IP를 가져옵니다. (기본값으로 Proxmox IP 세팅) -const REDIS_HOST = process.env.REDIS_HOST || "192.168.10.7"; +// Redis 호스트는 환경변수에서 가져옵니다. 미설정 시 부팅 시점에 명확하게 에러를 던집니다. +const REDIS_HOST = process.env.REDIS_HOST?.trim(); +const REDIS_PORT = Number(process.env.REDIS_PORT?.trim() ?? "6379"); + +if (!REDIS_HOST) { + throw new Error("[Redis] REDIS_HOST 환경변수가 설정되지 않았습니다."); +} +if (!Number.isFinite(REDIS_PORT) || REDIS_PORT <= 0) { + throw new Error(`[Redis] REDIS_PORT 값이 올바르지 않습니다: ${process.env.REDIS_PORT}`); +} // Next.js 개발 환경(HMR)에서 커넥션이 무한 증식하는 것을 막기 위한 글로벌 객체 선언 const globalForRedis = global as unknown as { @@ -9,16 +18,24 @@ const globalForRedis = global as unknown as { }; // 이미 연결된 객체가 있으면 그걸 쓰고, 없으면 새로 연결합니다. -export const Redis = globalForRedis.redisClient ?? new RedisClass({ host: REDIS_HOST, port: 6379 }); +export const Redis = globalForRedis.redisClient ?? new RedisClass({ + host: REDIS_HOST, + port: REDIS_PORT, + lazyConnect: false, + maxRetriesPerRequest: 3, +}); // 프로덕션(배포) 모드가 아닐 때만 글로벌 변수에 저장해 둡니다. if (process.env.NODE_ENV !== "production") globalForRedis.redisClient = Redis; // 연결 성공 시 로그 한 번만 찍기 +let connectLogged = false; Redis.on("connect", () => { - console.log("🟢 [Next.js] Proxmox Redis(우체국) 연결 완료!"); + if (connectLogged) return; + connectLogged = true; + Logger.ready(`Redis 연결 완료 (${REDIS_HOST}:${REDIS_PORT})`); }); Redis.on("error", (err) => { - console.error("❌ [Next.js] Redis 연결 에러:", err); -}); \ No newline at end of file + Logger.error(`Redis 연결 에러: ${err instanceof Error ? err.message : String(err)}`); +}); diff --git a/page/src/lib/api.ts b/page/src/lib/api.ts new file mode 100644 index 0000000..0c64c11 --- /dev/null +++ b/page/src/lib/api.ts @@ -0,0 +1,163 @@ +import { NextResponse } from "next/server"; +import { getServerSession, type Session } from "next-auth"; +import { randomUUID } from "node:crypto"; +import { Redis } from "@/lib/Redis"; +import { Logger } from "@/lib/Logger"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +// ========== 공용 응답 스키마 ========== +// 모든 API는 { success: boolean, ... } 형태로 응답한다. +// 실패시 { success: false, error: string } 보장. + +export const errorResponse = (message: string, status = 400) => + NextResponse.json({ success: false, error: message }, { status }); + +// ========== 세션 가드 ========== +// 세션이 없으면 401 NextResponse를 던지고, 있으면 세션을 돌려준다. +export async function requireSession(): Promise< + { ok: true; session: Session } | { ok: false; response: NextResponse } +> { + const session = (await getServerSession(authOptions)) as Session | null; + if (!session?.user?.id) { + return { ok: false, response: errorResponse("인증되지 않았습니다.", 401) }; + } + return { ok: true, session }; +} + +// ========== 봇 RPC 헬퍼 ========== +// site → bot: Redis Pub/Sub 으로 명령 전송 +// bot → site: Redis SET 으로 결과 저장 (resultKey) +// 사이트는 resultKey 를 short polling 으로 확인. + +export interface BotRpcOptions { + /** Redis 결과 키 prefix (e.g. "player:now") */ + channel: string; + /** 봇으로 보낼 페이로드. requestId는 자동 주입됨. */ + payload: Record; + /** 폴링 총 타임아웃 (ms). 기본 3000. */ + timeoutMs?: number; + /** 폴링 간격 (ms). 기본 100ms 시작 → 최대 400ms로 백오프. */ + pollIntervalMs?: number; + /** Redis 결과 키 만료(초). 기본 5초 — 클라이언트 타임아웃 후에도 키가 남아있는 것을 방지. */ + resultTtlSec?: number; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export async function botRpc( + opts: BotRpcOptions, +): Promise<{ status: number; body: Record }> { + const { + channel, + payload, + timeoutMs = 3000, + pollIntervalMs = 100, + resultTtlSec = 5, + } = opts; + + // CSPRNG 기반 requestId — Date.now() + Math.random() 충돌 가능성 제거 + const requestId = `req:${randomUUID()}`; + const resultKey = `${channel}:${requestId}`; + + // 봇에게 명령 전송 (action 필드는 호출부에서 payload 에 포함) + await Redis.publish( + "site-bot", + JSON.stringify({ ...payload, requestId }), + ); + + const deadline = Date.now() + timeoutMs; + let interval = pollIntervalMs; + + while (Date.now() < deadline) { + await sleep(interval); + interval = Math.min(interval * 2, 400); + + const reply = await Redis.get(resultKey); + if (!reply) continue; + + // 읽은 즉시 정리 (TTL 도 보험으로 깔려있음) + await Redis.del(resultKey); + + let parsed: Record; + try { + parsed = JSON.parse(reply); + } catch (err) { + Logger.error(`[botRpc:${channel}] 봇 응답 파싱 실패: ${String(err)}`); + return { + status: 502, + body: { success: false, error: "봇 응답을 해석할 수 없습니다." }, + }; + } + // 봇이 success 필드를 명시한 경우만 false로 평가. 없으면 데이터 응답으로 간주(200). + const hasSuccessField = "success" in parsed; + const ok = !hasSuccessField || parsed.success === true; + return { status: ok ? 200 : 400, body: parsed }; + } + + // 타임아웃 — 봇이 늦게 응답해도 메모리에 쌓이지 않도록 만료 설정 + // (resultKey 가 아직 없을 수 있으므로 expire 가 0 을 반환할 수 있음, 무해함) + try { + await Redis.expire(resultKey, resultTtlSec); + } catch { + // 무시: 정리 실패는 치명적 아님 + } + + return { + status: 504, + body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." }, + }; +} + +// ========== POST 본문 파싱 ========== +export async function readJsonBody( + request: Request, +): Promise<{ ok: true; data: T } | { ok: false; response: NextResponse }> { + try { + const data = (await request.json()) as T; + return { ok: true, data }; + } catch { + return { ok: false, response: errorResponse("요청 본문이 올바른 JSON이 아닙니다.", 400) }; + } +} + +// ========== 필수 필드 검증 ========== +export function requireString( + value: unknown, + field: string, +): { ok: true; value: string } | { ok: false; response: NextResponse } { + if (typeof value !== "string" || !value.trim()) { + return { ok: false, response: errorResponse(`${field} 정보가 필요합니다.`, 400) }; + } + return { ok: true, value: value.trim() }; +} + +export function requireNumber( + value: unknown, + field: string, + opts?: { min?: number; max?: number; integer?: boolean }, +): { ok: true; value: number } | { ok: false; response: NextResponse } { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) { + return { ok: false, response: errorResponse(`${field} 값이 올바르지 않습니다.`, 400) }; + } + if (opts?.integer && !Number.isInteger(n)) { + return { ok: false, response: errorResponse(`${field} 는 정수여야 합니다.`, 400) }; + } + if (opts?.min !== undefined && n < opts.min) { + return { ok: false, response: errorResponse(`${field} 가 너무 작습니다.`, 400) }; + } + if (opts?.max !== undefined && n > opts.max) { + return { ok: false, response: errorResponse(`${field} 가 너무 큽니다.`, 400) }; + } + return { ok: true, value: n }; +} + +export function requireBoolean( + value: unknown, + field: string, +): { ok: true; value: boolean } | { ok: false; response: NextResponse } { + if (typeof value !== "boolean") { + return { ok: false, response: errorResponse(`${field} 는 boolean 이어야 합니다.`, 400) }; + } + return { ok: true, value }; +} diff --git a/page/src/lib/sse.ts b/page/src/lib/sse.ts new file mode 100644 index 0000000..673220a --- /dev/null +++ b/page/src/lib/sse.ts @@ -0,0 +1,129 @@ +import { NextRequest } from "next/server"; +import { getServerSession } from "next-auth"; +import { Redis } from "@/lib/Redis"; +import { Logger } from "@/lib/Logger"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +interface BotEvent { + event?: unknown; + guildId?: unknown; + [key: string]: unknown; +} + +interface BotEventStreamOptions { + /** bot 이 발행하는 event 이름 (e.g. "player_update", "queue_update"). */ + botEventName: string; + /** 클라이언트로 보낼 SSE type. 기본: botEventName 과 동일. */ + clientEventType?: string; +} + +/** + * 봇이 publish 하는 "bot-site" 채널을 구독해서 SSE 로 흘려보내는 공용 핸들러. + * - 인증 가드: 세션 없으면 401 + * - serverId 검증 + * - JSON.parse 안전 처리 + * - subscriber error / 클라이언트 abort 모두에서 깔끔히 정리 + * - keepalive ping (30초) + */ +export async function botEventStream(req: NextRequest, opts: BotEventStreamOptions): Promise { + const clientEventType = opts.clientEventType ?? opts.botEventName; + + const session = await getServerSession(authOptions); + if (!session) { + return new Response("인증되지 않았습니다.", { status: 401 }); + } + + const serverId = req.nextUrl.searchParams.get("serverId")?.trim(); + if (!serverId) { + return new Response("Missing serverId", { status: 400 }); + } + + const stream = new ReadableStream({ + async start(controller) { + const subscriber = Redis.duplicate(); + let closed = false; + const timers: NodeJS.Timeout[] = []; + + const cleanup = async () => { + if (closed) return; + closed = true; + for (const t of timers) clearInterval(t); + try { + await subscriber.unsubscribe("bot-site"); + } catch { + /* noop */ + } + try { + await subscriber.quit(); + } catch { + /* noop */ + } + try { + controller.close(); + } catch { + /* 이미 닫혔을 수 있음 */ + } + }; + + subscriber.on("error", async (err) => { + Logger.error(`[SSE:${opts.botEventName}] subscriber error: ${err.message}`); + await cleanup(); + }); + + try { + await subscriber.subscribe("bot-site"); + } catch (err) { + Logger.error(`[SSE:${opts.botEventName}] subscribe 실패: ${String(err)}`); + await cleanup(); + return; + } + + subscriber.on("message", (channel, message) => { + if (channel !== "bot-site" || closed) return; + let data: BotEvent; + try { + data = JSON.parse(message) as BotEvent; + } catch (err) { + Logger.warn(`[SSE:${opts.botEventName}] 잘못된 JSON: ${String(err)}`); + return; + } + if (data.guildId !== serverId) return; + if (data.event !== opts.botEventName) return; + try { + controller.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify({ type: clientEventType })}\n\n`), + ); + } catch (err) { + Logger.warn(`[SSE:${opts.botEventName}] enqueue 실패: ${String(err)}`); + void cleanup(); + } + }); + + // 30초마다 keep-alive 코멘트 전송 (프록시 timeout 방지) + timers.push( + setInterval(() => { + if (closed) return; + try { + controller.enqueue(new TextEncoder().encode(`: keep-alive\n\n`)); + } catch { + void cleanup(); + } + }, 30000), + ); + + // 클라이언트가 연결을 끊으면 정리 + req.signal.addEventListener("abort", () => { + void cleanup(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/page/src/types/music.ts b/page/src/types/music.ts new file mode 100644 index 0000000..e7583ee --- /dev/null +++ b/page/src/types/music.ts @@ -0,0 +1,42 @@ +// 공용 도메인 타입 — 봇/Discord/Lavalink 데이터 표현. + +export interface DiscordServer { + id: string; + name: string; + icon: string | null; + owner: boolean; + permissions: string; +} + +export interface TrackInfo { + identifier?: string; + title?: string; + author?: string; + length?: number; + artworkUrl?: string; + uri?: string; +} + +export interface Track { + encoded?: string; + info?: TrackInfo; + // 큐 항목에는 종종 추가 메타데이터(요청자 등)가 붙음. + [key: string]: unknown; +} + +// 검색 결과(곡 카드) 공용 타입 — Spotify/YT Music/YT Video 모두 매핑됨. +export interface SearchTrack { + videoId?: string; + id?: string; + url?: string; + title?: string; + artist?: string; + thumbnail?: string; + duration?: number; +} + +export interface SearchResults { + spotify: SearchTrack[]; + youtubeMusic: SearchTrack[]; + youtubeVideo: SearchTrack[]; +} diff --git a/page/src/types/next-auth.d.ts b/page/src/types/next-auth.d.ts new file mode 100644 index 0000000..df6aa0a --- /dev/null +++ b/page/src/types/next-auth.d.ts @@ -0,0 +1,21 @@ +import "next-auth"; +import "next-auth/jwt"; + +declare module "next-auth" { + interface Session { + accessToken?: string; + user: { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + }; + } +} + +declare module "next-auth/jwt" { + interface JWT { + id?: string; + accessToken?: string; + } +} diff --git a/page/tsconfig.json b/page/tsconfig.json index cf9c65d..75d74a7 100644 --- a/page/tsconfig.json +++ b/page/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2020", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, From 26393fec2fc8224afc5bcbc44d12c4ea7930719d Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 28 Apr 2026 16:09:29 +0900 Subject: [PATCH 4/5] =?UTF-8?q?bot/utils/shuffle.ts=20=E2=86=92=20Shuffle.?= =?UTF-8?q?ts=20=EB=8C=80=EB=AC=B8=EC=9E=90=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리눅스 빌드 환경에서 대소문자 잔존 파일과 충돌(TS1261)을 막기 위해 파일명을 PascalCase 로 통일하고 import 경로도 동일하게 수정. --- bot/src/classes/GuildPlayer.ts | 2 +- bot/src/classes/LavalinkManager.ts | 2 +- bot/src/utils/{shuffle.ts => Shuffle.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename bot/src/utils/{shuffle.ts => Shuffle.ts} (100%) diff --git a/bot/src/classes/GuildPlayer.ts b/bot/src/classes/GuildPlayer.ts index 9948ec5..8855cf3 100644 --- a/bot/src/classes/GuildPlayer.ts +++ b/bot/src/classes/GuildPlayer.ts @@ -5,7 +5,7 @@ import { timeFormat } from "../utils/music/Utils"; import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config"; import { GuildType } from "../../db/db"; import { DB } from "../utils/Database"; -import { shuffle } from "../utils/shuffle"; +import { shuffle } from "../utils/Shuffle"; import { checkTextChannelAndMsg } from "../utils/music/Channel"; import { Logger } from "../utils/Logger"; diff --git a/bot/src/classes/LavalinkManager.ts b/bot/src/classes/LavalinkManager.ts index fc6cbae..7f51c73 100644 --- a/bot/src/classes/LavalinkManager.ts +++ b/bot/src/classes/LavalinkManager.ts @@ -4,7 +4,7 @@ import { GuildPlayer } from "./GuildPlayer"; import { Config } from "../utils/Config"; import { Logger } from "../utils/Logger"; import { parseLink } from "../utils/music/Url"; -import { shuffle } from "../utils/shuffle"; +import { shuffle } from "../utils/Shuffle"; import { Spotify } from "../utils/api/Spotify"; import { YoutubeMusic } from "../utils/api/YoutubeMusic"; diff --git a/bot/src/utils/shuffle.ts b/bot/src/utils/Shuffle.ts similarity index 100% rename from bot/src/utils/shuffle.ts rename to bot/src/utils/Shuffle.ts From a4b3d40efa6e12ffd696fd5194ba88561864936f Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Thu, 14 May 2026 11:27:56 +0900 Subject: [PATCH 5/5] =?UTF-8?q?bot:=20=EC=9D=8C=EC=84=B1=20WS=20=EB=81=8A?= =?UTF-8?q?=EA=B9=80=20=EC=A7=84=EB=8B=A8=20=EB=A1=9C=EA=B9=85=20+=20?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=ED=9A=8C=EB=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - closed 이벤트에서 code/reason/byRemote 로깅 (4006/4014/4015 등 원인 식별) - 5초 후 봇이 보이스 채널에 남아있으면 player를 새로 만들어 현재 곡을 position 그대로 이어서 재생 (일시정지/볼륨 상태도 복원) - 재접속 실패 시에만 기존처럼 player 정리 - 라우터/네트워크 일시 끊김(4006 세션 만료 등) 시 사용자 체감 끊김 최소화 --- bot/src/classes/GuildPlayer.ts | 86 +++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 11 deletions(-) diff --git a/bot/src/classes/GuildPlayer.ts b/bot/src/classes/GuildPlayer.ts index 8855cf3..67fa9d7 100644 --- a/bot/src/classes/GuildPlayer.ts +++ b/bot/src/classes/GuildPlayer.ts @@ -1,5 +1,5 @@ import { Guild, Message, TextChannel } from "discord.js"; -import { LoadType, Player, Track, TrackEndEvent, TrackStartEvent } from "shoukaku"; +import { LoadType, Player, Track, TrackEndEvent, TrackStartEvent, type WebSocketClosedEvent } from "shoukaku"; import { client, lavalinkManager, Redis } from "../index"; import { timeFormat } from "../utils/music/Utils"; import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config"; @@ -32,6 +32,10 @@ export class GuildPlayer { public msg: Message, ) { this.player.setGlobalVolume(50); + this.attachPlayerListeners(); + } + + private attachPlayerListeners() { this.player.on("start", (_data: TrackStartEvent) => { // endTimer가 남아있으면 제거 (새 곡 재생 시작) if (this.endTimer !== undefined) { @@ -56,31 +60,35 @@ export class GuildPlayer { this.end(); } } catch (err) { - Logger.error(`[GuildPlayer] end 이벤트 ��리 중 에러: ${String(err)}`); + Logger.error(`[GuildPlayer] end 이벤트 처리 중 에러: ${String(err)}`); } }); - this.player.on("closed", () => { + this.player.on("closed", (data: WebSocketClosedEvent) => { if (this.isDead) return; - Logger.info(`[GuildPlayer] 음성 연결이 끊어졌습니다. 재접속을 대기합니다...`); + Logger.warn( + `[GuildPlayer] 음성 WS 끊김 (code=${data.code}, reason="${data.reason || "(none)"}", byRemote=${data.byRemote}). 5초 후 복구 시도...`, + ); // 이전 closed 타이머가 있으면 제거 if (this.closedTimer !== undefined) { clearTimeout(this.closedTimer); } - this.closedTimer = setTimeout(() => { + this.closedTimer = setTimeout(async () => { this.closedTimer = undefined; if (this.isDead) return; - // 5초가 지났는데도 연결이 복구되지 않았을 때만 방을 나갑니다. // 디스코드 방에 내 봇(me)이 없으면 봇을 삭제(delete)한다! - if (!this.guild.members.me?.voice?.channelId) { + const meChannelId = this.guild.members.me?.voice?.channelId; + if (!meChannelId) { Logger.warn(`[GuildPlayer] 음성채널에 봇이 없습니다. player를 초기화합니다.`); return this.delete(); } - // (1 = CONNECTED, Shoukaku 버전에 따라 연결 상태 체크가 다를 수 있으�� 안전하게 확인) - if (this.player && this.player.node.state !== 1) { - Logger.warn(`[GuildPlayer] 연결 복구 실패. 봇을 퇴장시킵니다.`); - return this.delete(); + // 봇이 아직 보이스 채널에 남아있으면 자가 회복 시도 + try { + await this.reconnect(meChannelId); + } catch (err) { + Logger.error(`[GuildPlayer] 음성 재접속 실패: ${String(err)}. player를 초기화합니다.`); + this.delete(); } }, 5000); }); @@ -103,6 +111,62 @@ export class GuildPlayer { }); } + /** 음성 WS가 끊겼지만 봇이 아직 보이스 채널에 남아있을 때, player를 새로 만들어 현재 곡을 이어서 재생. */ + private async reconnect(targetChannelId: string) { + const currentTrack = this.nowTrack; + const currentPosition = this.position; + const wasPaused = this.isPaused; + const savedVolume = this.player.volume; + + Logger.info( + `[GuildPlayer] 음성 재접속 시도 (channel=${targetChannelId}, track="${currentTrack?.info.title ?? "(none)"}", position=${currentPosition}ms, paused=${wasPaused})`, + ); + + // 기존 player listener 해제 + 정리 + this.player.removeAllListeners(); + try { + await this.player.destroy(); + } catch (err) { + Logger.warn(`[GuildPlayer] 기존 player.destroy 중 에러(무시): ${String(err)}`); + } + try { + await lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id); + } catch (err) { + Logger.warn(`[GuildPlayer] 기존 leaveVoiceChannel 중 에러(무시): ${String(err)}`); + } + + if (this.isDead) return; + + // 게이트웨이 측 정리 대기 + await new Promise((r) => setTimeout(r, 500)); + if (this.isDead) return; + + // 재접속 (봇이 옮겨졌을 수도 있으니 me의 현재 채널로) + const newPlayer = await lavalinkManager.shoukaku.joinVoiceChannel({ + guildId: this.guild.id, + channelId: targetChannelId, + shardId: this.guild.shardId, + deaf: true, + mute: false, + }); + this.player = newPlayer; + this.voiceChannelId = targetChannelId; + this.attachPlayerListeners(); + await this.player.setGlobalVolume(savedVolume || 50); + + // 곡이 있었으면 이어서 재생 + if (currentTrack) { + await this.player.playTrack({ + track: { encoded: currentTrack.encoded }, + position: currentPosition, + paused: wasPaused, + }); + Logger.info(`[GuildPlayer] 음성 재접속 성공. 곡을 이어서 재생합니다.`); + } else { + Logger.info(`[GuildPlayer] 음성 재접속 성공 (재생 중인 곡 없음).`); + } + } + private get GDB() { if (!this._GDB) this._GDB = DB.guild.get(this.guild.id); return this._GDB;