bot 전체 코드 품질 개선 및 버그 수정

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 23:13:16 +09:00
parent fefdf1fcdb
commit d0dcdb1563
19 changed files with 178 additions and 162 deletions

View File

@@ -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)));
}
}

View File

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

View File

@@ -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<string, Command> = 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(() => {});
}
}
}

View File

@@ -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";

View File

@@ -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 위치<EC9C84><ECB998> 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보다 크거나 같아야합니다." }));