From a4b3d40efa6e12ffd696fd5194ba88561864936f Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Thu, 14 May 2026 11:27:56 +0900 Subject: [PATCH] =?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;