diff --git a/bot/src/classes/RedisClient.ts b/bot/src/classes/RedisClient.ts index 9089f42..906d0e9 100644 --- a/bot/src/classes/RedisClient.ts +++ b/bot/src/classes/RedisClient.ts @@ -63,56 +63,56 @@ class RedisClientClass { thumbnail: video.info.artworkUrl ?? "", duration: video.info.length, })); - await this.pub.setex(resultKey, 60, JSON.stringify({ spotify, youtubeMusic, youtubeVideo })); - Logger.log(`[Redis Pub] [setex] 결과 저장: (${resultKey})`); + await this.reply(resultKey, 60, JSON.stringify({ spotify, youtubeMusic, youtubeVideo })); + Logger.log(`[Redis Pub] [reply] 결과 저장: (${resultKey})`); } if (data.action === "player_play") { const resultKey = `player:play:${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.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); const guild = await getGuildById(data.serverId); - if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); - if (!(await this.isMember(guild, data.userId))) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); + if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); let player = lavalinkManager.getPlayer(guild.id); const voiceChannel = await getVoiceChannelById(guild, data.userId); if (!player) { - if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); + if (!voiceChannel) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); player = (await channelJoin(guild, voiceChannel.id)).player; } else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) { - return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." })); + return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." })); } - if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); + if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); await lavalinkManager.search(guild.id, data.track.url, data.userId, player); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" })); + await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" })); } if (data.action === "player_playlist") { const resultKey = `player:playlist:${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.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); const guild = await getGuildById(data.serverId); - if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); - if (!(await this.isMember(guild, data.userId))) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); + if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); let player = lavalinkManager.getPlayer(guild.id); const voiceChannel = await getVoiceChannelById(guild, data.userId); if (!player) { - if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); + if (!voiceChannel) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); player = (await channelJoin(guild, voiceChannel.id)).player; } else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) { - return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." })); + return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." })); } - if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); + if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); await lavalinkManager.search(guild.id, data.playlistUrl, data.userId, player); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" })); + await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" })); } if (data.action === "player_now") { const resultKey = `player:now:${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.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); const nowGuild = await getGuildById(data.serverId); - if (!nowGuild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); - if (!(await this.isMember(nowGuild, data.userId))) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); + if (!nowGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + if (!(await this.isMember(nowGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); const player = lavalinkManager.getPlayer(data.serverId); - await this.pub.setex(resultKey, 60, JSON.stringify({ + await this.reply(resultKey, 60, JSON.stringify({ success: true, botPlayer: !!player, isPlaying: player?.isPlaying, @@ -124,19 +124,19 @@ class RedisClientClass { } if (data.action === "queue_list") { const resultKey = `queue:list:${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.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); const qlGuild = await getGuildById(data.serverId); - if (!qlGuild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); - if (!(await this.isMember(qlGuild, data.userId))) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); + if (!qlGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + if (!(await this.isMember(qlGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); const player = lavalinkManager.getPlayer(data.serverId); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] })); + await this.reply(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] })); } if (data.action === "queue_set") { const resultKey = `queue:set:${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.newQueue) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "newQueue를 찾을수 없습니다." })); + if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); + if (!data.newQueue) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "newQueue를 찾을수 없습니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; const nowTrack = context.player.nowTrack ?? context.player.queue?.[0]; @@ -145,85 +145,85 @@ class RedisClientClass { for (const rawTrack of data.newQueue) { if (rawTrack.encoded) context.player.queue.push(rawTrack); } - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true })); + await this.reply(resultKey, 60, JSON.stringify({ success: true })); context.player.setMsg(); } if (data.action === "queue_remove") { 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.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); // index 는 number(0 도 유효) — typeof 검증으로 변경. - if (typeof data.index !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." })); + if (typeof data.index !== "number") return await this.reply(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); - 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 (isNaN(numIndex)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." })); + if (numIndex < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." })); // 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가 대기열 범위를 초과합니다." })); + if (numIndex >= context.player.queue.length - 1) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." })); // 인덱스 신뢰 대신, 클라이언트가 지우려던 곡(encoded)과 실제 대상이 같은지 확인. // SSE로 큐가 갱신되는 찰나 인덱스가 밀려 다른 곡이 삭제되는 것을 방지. const removeTarget = context.player.queue[numIndex + 1]; if (data.encoded && removeTarget?.encoded && removeTarget.encoded !== data.encoded) { - return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "대기열이 변경되었습니다. 새로고침 후 다시 시도해주세요." })); + return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "대기열이 변경되었습니다. 새로고침 후 다시 시도해주세요." })); } const [removedTrack] = context.player.queue.splice(numIndex + 1, 1); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, removedTrack })); + await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack })); context.player.setMsg(); } if (data.action === "player_paused") { 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.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); // isPaused 는 boolean — false 도 정상 입력. typeof 검증으로 변경. - if (typeof data.isPaused !== "boolean") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." })); + if (typeof data.isPaused !== "boolean") return await this.reply(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(); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused })); + await this.reply(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused })); } if (data.action === "player_skip") { const resultKey = `player:skip:${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.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; if (context.player.isPlaying) context.player.skip(); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true })); + await this.reply(resultKey, 60, JSON.stringify({ success: true })); } if (data.action === "player_seek") { 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.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); // seek 는 number(0 도 유효 — 처음으로 되감기) — typeof 검증으로 변경. - if (typeof data.seek !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." })); + if (typeof data.seek !== "number") return await this.reply(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: "재생중인 노래가 없습니다." })); + if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(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보다 크거나 같아야합니다." })); - if (numSeek > duration) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." })); + if (isNaN(numSeek)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." })); + if (numSeek < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." })); + if (numSeek > duration) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." })); context.player.seek(numSeek); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true })); + await this.reply(resultKey, 60, JSON.stringify({ success: true })); } if (data.action === "player_volume") { 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.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." })); + if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." })); // volume 은 number(0 도 유효 — 음소거) — typeof 검증으로 변경. - if (typeof data.volume !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." })); + if (typeof data.volume !== "number") return await this.reply(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: "재생중인 노래가 없습니다." })); + if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(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보다 크거나 같아야합니다." })); - if (numVolume > 100) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." })); + if (isNaN(numVolume)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." })); + if (numVolume < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." })); + if (numVolume > 100) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." })); context.player.setVolume(numVolume); - await this.pub.setex(resultKey, 60, JSON.stringify({ success: true })); + await this.reply(resultKey, 60, JSON.stringify({ success: true })); } } catch (err) { Logger.error(`명령어 처리 중 에러: ${String(err)}`); @@ -238,6 +238,17 @@ class RedisClientClass { }); } + /** + * 사이트로 보내는 RPC 응답 쓰기. + * 기존 SET+폴링 대신, 사이트가 BRPOP 으로 블로킹 대기하도록 리스트에 LPUSH 한다. + * ttlSec 는 사이트가 이미 타임아웃된 뒤 남는 고아 응답을 자동 정리하기 위한 보험. + * (시그니처를 기존 setex(key, ttl, value) 와 동일하게 맞춰 호출부를 기계적으로 교체) + */ + private async reply(key: string, ttlSec: number, value: string): Promise { + await this.pub.lpush(key, value); + await this.pub.expire(key, ttlSec); + } + public publishState(event: string, data: any) { const payload = JSON.stringify({ event, @@ -266,29 +277,29 @@ class RedisClientClass { } | { ok: false; }> { const guild = await getGuildById(guildId); if (!guild) { - await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); return { ok: false }; } // 인가: 요청자가 이 서버의 멤버여야 함 (남의 서버 제어 차단) if (!(await this.isMember(guild, userId))) { - await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); + await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); return { ok: false }; } let player = lavalinkManager.getPlayer(guild.id); const voiceChannel = await getVoiceChannelById(guild, userId); if (!player) { if (!voiceChannel) { - await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); + await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); return { ok: false }; } player = (await channelJoin(guild, voiceChannel.id)).player; } else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) { // 이미 재생 중이면 봇과 같은 음성채널에 있는 사람만 조작 가능 - await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." })); + await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." })); return { ok: false }; } if (!player) { - await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." })); + await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." })); return { ok: false }; } return { diff --git a/page/src/app/api/search/route.ts b/page/src/app/api/search/route.ts index 956db0d..69afb0e 100644 --- a/page/src/app/api/search/route.ts +++ b/page/src/app/api/search/route.ts @@ -20,7 +20,6 @@ export async function GET(request: Request) { query, }, timeoutMs: 10000, - pollIntervalMs: 250, }); return NextResponse.json(body, { status }); } catch (error) { diff --git a/page/src/lib/api.ts b/page/src/lib/api.ts index 17d1d70..848a096 100644 --- a/page/src/lib/api.ts +++ b/page/src/lib/api.ts @@ -25,35 +25,23 @@ export async function requireSession(): Promise< } // ========== 봇 RPC 헬퍼 ========== -// site → bot: Redis Pub/Sub 으로 명령 전송 -// bot → site: Redis SET 으로 결과 저장 (resultKey) -// 사이트는 resultKey 를 short polling 으로 확인. +// site → bot: Redis Pub/Sub 으로 명령 전송 (채널 "site-bot") +// bot → site: 봇이 결과를 resultKey 리스트에 LPUSH +// 사이트는 resultKey 를 BRPOP 으로 블로킹 대기한다(폴링 없음 → 응답 즉시, 헛 GET 제거). export interface BotRpcOptions { /** Redis 결과 키 prefix (e.g. "player:now") */ channel: string; /** 봇으로 보낼 페이로드. requestId는 자동 주입됨. */ payload: Record; - /** 폴링 총 타임아웃 (ms). 기본 3000. */ + /** 응답 대기 타임아웃 (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; + const { channel, payload, timeoutMs = 3000 } = opts; // CSPRNG 기반 requestId — Date.now() + Math.random() 충돌 가능성 제거 const requestId = `req:${randomUUID()}`; @@ -65,26 +53,24 @@ export async function botRpc( JSON.stringify({ ...payload, requestId }), ); - const deadline = Date.now() + timeoutMs; - // 봇은 보통 수십 ms 안에 응답하므로, 먼저 즉시 확인하고 촘촘히 폴링한다(초기 지연 제거). - // (이상적으로는 봇이 reply 채널로 publish → 사이트가 구독하는 pub/sub 방식이나, - // 봇의 30여 개 setex 지점을 모두 바꿔야 해 회귀 위험이 커서 폴링 최적화로 대체.) - let interval = Math.min(pollIntervalMs, 30); - let first = true; + // BRPOP 은 응답이 올 때까지 커넥션을 블로킹하므로, 공유 커넥션을 막지 않도록 + // 요청마다 전용 커넥션을 복제해서 쓰고 끝나면 닫는다. + // (봇이 LPUSH 를 이미 끝냈다면 BRPOP 은 즉시 반환하므로 publish→BRPOP 순서 경쟁 문제 없음) + const waiter = Redis.duplicate(); + // BRPOP 타임아웃은 초 단위. ms 를 올림(최소 1초)해서 전달. + const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000)); - while (Date.now() < deadline) { - if (!first) { - await sleep(interval); - interval = Math.min(Math.floor(interval * 1.6), 250); + try { + // brpop → [key, value] | null(타임아웃) + const popped = await waiter.brpop(resultKey, timeoutSec); + if (!popped) { + return { + status: 504, + body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." }, + }; } - first = false; - - const reply = await Redis.get(resultKey); - if (!reply) continue; - - // 읽은 즉시 정리 (TTL 도 보험으로 깔려있음) - await Redis.del(resultKey); + const reply = popped[1]; let parsed: Record; try { parsed = JSON.parse(reply); @@ -99,20 +85,10 @@ export async function botRpc( const hasSuccessField = "success" in parsed; const ok = !hasSuccessField || parsed.success === true; return { status: ok ? 200 : 400, body: parsed }; + } finally { + // 전용 커넥션 정리 (블로킹 중이면 즉시 끊기) + waiter.disconnect(); } - - // 타임아웃 — 봇이 늦게 응답해도 메모리에 쌓이지 않도록 만료 설정 - // (resultKey 가 아직 없을 수 있으므로 expire 가 0 을 반환할 수 있음, 무해함) - try { - await Redis.expire(resultKey, resultTtlSec); - } catch { - // 무시: 정리 실패는 치명적 아님 - } - - return { - status: 504, - body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." }, - }; } // ========== POST 본문 파싱 ==========