import { Redis } from "ioredis"; import { Config } from "../utils/Config"; import { Logger } from "../utils/Logger"; import { YoutubeMusic } from "../utils/api/YoutubeMusic"; import { Spotify } from "../utils/api/Spotify"; import { lavalinkManager } from "../index"; import { getGuildById, getVoiceChannelById } from "../utils/music/Channel"; import { channelJoin } from "../commands/join"; import { GuildPlayer } from "./GuildPlayer"; import { Guild, VoiceChannel } from "discord.js"; import { SongItem } from "../types/Track"; type SubAction = "search" | "player_now" | "player_play" | "player_playlist" | "player_paused" | "player_skip" | "player_seek" | "player_volume" | "queue_list" | "queue_set" | "queue_remove"; export const RedisClient = () => { if (Config.redis.state) return new RedisClientClass(); return null; } class RedisClientClass { public pub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port }); public sub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port }); constructor() { this.pub.on("connect", () => { Logger.ready(`[Redis Pub] 연결 완료 (말하는 입)`); }); this.sub.on("connect", () => { Logger.ready(`[Redis Sub] 연결 완료 (듣는 귀)`); }); this.sub.subscribe("site-bot", (err, count) => { if (err) return Logger.error(`[Redis Sub] 구독 실패: ${err.message}`); Logger.log(`[Redis Sub] 'bot-commands' 채널 구독 중... (현재 구독 채널 수: ${count})`); }); this.sub.on("message", async (ch, msg): Promise => { if (ch !== "site-bot") return; Logger.log(`[Redis Sub] [Message] 수신: {\n 채널: ${ch}\n 내용: ${msg}\n}`); try { const data = JSON.parse(msg) as { action: SubAction; requestId: string; userId?: string; [key: string]: any; }; if (data.action === "search") { const resultKey = `search:${data.requestId}`; const spotify: SongItem[] = (await Spotify.getSearchFull(data.query) ?? []).slice(0,10); const youtubeMusic: SongItem[] = (await YoutubeMusic.getSearchFull(data.query) ?? []).slice(0,10); const youtubeVideo: SongItem[] = (await lavalinkManager.youtubeSearch(data.query) ?? []).slice(0,10).map((video) => ({ videoId: video.info.identifier, url: `https://www.youtube.com/watch?v=${video.info.identifier}`, title: video.info.title, artist: video.info.author, 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})`); } 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를 찾을수 없습니다." })); const guild = await getGuildById(data.serverId); if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); 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: "음성채널에 들어가서 이용해주세요." })); player = (await channelJoin(guild, voiceChannel.id)).player; } if (!player) return await this.pub.setex(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: "노래 추가 완료" })); } 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를 찾을수 없습니다." })); const guild = await getGuildById(data.serverId); if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); 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: "음성채널에 들어가서 이용해주세요." })); player = (await channelJoin(guild, voiceChannel.id)).player; } if (!player) return await this.pub.setex(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: "플레이리스트 추가 완료" })); } 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를 찾을수 없습니다." })); const player = lavalinkManager.getPlayer(data.serverId); // if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." })); await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, botPlayer: !!player, isPlaying: player?.isPlaying, isPaused: player?.isPaused, position: player?.position, volume: player?.volume, track: player?.nowTrack ?? null })); } 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를 찾을수 없습니다." })); const player = lavalinkManager.getPlayer(data.serverId); // if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." })); await this.pub.setex(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를 찾을수 없습니다." })); const context = await this.getContext(data.serverId, resultKey, data.userId); if (!context.ok) return; const nowTrack = context.player.nowTrack ?? context.player.queue?.[0]; context.player.queue.length = 0; if (nowTrack) context.player.queue.push(nowTrack); for (const rawTrack of data.newQueue) { if (rawTrack.encoded) context.player.queue.push(rawTrack); } await this.pub.setex(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를 찾을수 없습니다." })); // 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); 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보다 크거나 같아야합니다." })); // 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(); } 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를 찾을수 없습니다." })); // 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(); await this.pub.setex(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를 찾을수 없습니다." })); 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 })); } 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를 찾을수 없습니다." })); // 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: "재생중인 노래가 없습니다." })); 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보다 클수 없습니다." })); context.player.seek(numSeek); await this.pub.setex(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를 찾을수 없습니다." })); // 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: "재생중인 노래가 없습니다." })); 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보다 클수 없습니다." })); context.player.setVolume(numVolume); await this.pub.setex(resultKey, 60, JSON.stringify({ success: true })); } } catch (err) { Logger.error(`명령어 처리 중 에러: ${String(err)}`); } }); this.pub.on("error", (err) => { Logger.error(`[Redis Pub] [Error] ${err.message}`); }); this.sub.on("error", (err) => { Logger.error(`[Redis Sub] [Error] ${err.message}`); }); } public publishState(event: string, data: any) { const payload = JSON.stringify({ event, timestamp: Date.now(), ...data, }); this.pub.publish("bot-site", payload); Logger.log(`[Redis Pub] bot -> site 전송: ${event}`); } private async getContext(guildId: string, resultKey: string, userId: string): Promise<{ ok: true; guild: Guild; voiceChannel: VoiceChannel | null; player: GuildPlayer; } | { ok: false; }> { const guild = await getGuildById(guildId); if (!guild) { await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); 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: "음성채널에 들어가서 이용해주세요." })); return { ok: false }; } player = (await channelJoin(guild, voiceChannel.id)).player; } if (!player) { await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." })); return { ok: false }; } return { ok: true, guild, voiceChannel, player, }; } public runTest() { Logger.debug(`[Redis Test] 3초 뒤에 테스트 통신 시작...`); setTimeout(() => { // 1. 봇 -> 사이트(웹) 방향 전송 테스트 this.publishState("TRACK_START", { author: "테스트", title: "제목", duration: 196000, }); // 2. 사이트(웹) -> 봇 방향 수신 테스트 (가짜 명령을 쏴서 스스로 수신하는지 확인) setTimeout(() => { const mockCommand = JSON.stringify({ action: "skip", userId: "12345" }); // 테스트를 위해 본인이 site-bot 채널로 발행해 봅니다. this.pub.publish("site-bot", mockCommand); }, 1000); }, 3000); } }