bot: 음성 WS 끊김 진단 로깅 + 자가 회복

- closed 이벤트에서 code/reason/byRemote 로깅 (4006/4014/4015 등 원인 식별)
- 5초 후 봇이 보이스 채널에 남아있으면 player를 새로 만들어 현재 곡을
  position 그대로 이어서 재생 (일시정지/볼륨 상태도 복원)
- 재접속 실패 시에만 기존처럼 player 정리
- 라우터/네트워크 일시 끊김(4006 세션 만료 등) 시 사용자 체감 끊김 최소화
This commit is contained in:
tkrmagid
2026-05-14 11:27:56 +09:00
parent 26393fec2f
commit a4b3d40efa

View File

@@ -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 이벤트 <EFBFBD><EFBFBD>리 중 에러: ${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 버전에 따라 연결 상태 체크가 다를 수 있으<EC9E88><EC9CBC> 안전하게 확인)
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;