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

6
bot/db/db.d.ts vendored
View File

@@ -8,9 +8,3 @@ export interface GuildType {
}; };
} }
export type GuildRow = Omit<GuildType, "options"> & { options: string }; export type GuildRow = Omit<GuildType, "options"> & { options: string };
// export interface UserType {
// guild_id: string;
// id: string;
// name: string;
// }

View File

@@ -20,7 +20,6 @@
"typescript": "^6.0.2" "typescript": "^6.0.2"
}, },
"dependencies": { "dependencies": {
"axios": "^1.14.0",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"colors": "^1.4.0", "colors": "^1.4.0",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",

View File

@@ -1,5 +1,6 @@
import { Client, ClientEvents, ColorResolvable, EmbedBuilder, EmbedField, GatewayIntentBits, Message } from "discord.js"; import { Client, ClientEvents, ColorResolvable, EmbedBuilder, EmbedField, GatewayIntentBits, Message } from "discord.js";
import { Config } from "../utils/Config"; import { Config } from "../utils/Config";
import { Logger } from "../utils/Logger";
export class BotClient extends Client { export class BotClient extends Client {
public prefix = Config.prefix; public prefix = Config.prefix;
@@ -69,8 +70,10 @@ export class BotClient extends Client {
setTimeout(async () => { setTimeout(async () => {
try { try {
const msg = await message.fetch(true).catch(() => undefined); const msg = await message.fetch(true).catch(() => undefined);
if (msg?.deletable) msg.delete().catch(() => {}); if (msg?.deletable) msg.delete().catch((err) => {
} catch {}; Logger.warn(`[BotClient] 메세지 삭제 실패: ${String(err)}`);
});
} catch {}
}, Math.max(100, time * (customTime ? 1 : 6000))); }, 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 { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
import { GuildType } from "../../db/db"; import { GuildType } from "../../db/db";
import { DB } from "../utils/Database"; import { DB } from "../utils/Database";
import { shuffle } from "../utils/Shuffle"; import { shuffle } from "../utils/shuffle";
import { checkTextChannelAndMsg } from "../utils/music/Channel"; import { checkTextChannelAndMsg } from "../utils/music/Channel";
import { Logger } from "../utils/Logger"; import { Logger } from "../utils/Logger";
@@ -22,6 +22,7 @@ export class GuildPlayer {
public queue: QueueTrack[] = []; public queue: QueueTrack[] = [];
private errorTimer: NodeJS.Timeout | undefined; private errorTimer: NodeJS.Timeout | undefined;
private endTimer: NodeJS.Timeout | undefined; private endTimer: NodeJS.Timeout | undefined;
private closedTimer: NodeJS.Timeout | undefined;
constructor( constructor(
public guild: Guild, public guild: Guild,
@@ -32,11 +33,17 @@ export class GuildPlayer {
) { ) {
this.player.setGlobalVolume(50); this.player.setGlobalVolume(50);
this.player.on("start", (_data: TrackStartEvent) => { this.player.on("start", (_data: TrackStartEvent) => {
// endTimer가 남아있으면 제거 (새 곡 재생 시작)
if (this.endTimer !== undefined) {
clearTimeout(this.endTimer);
this.endTimer = undefined;
}
Redis?.publishState("player_update", { Redis?.publishState("player_update", {
guildId: this.guild.id, guildId: this.guild.id,
}); });
}); });
this.player.on("end", async (data: TrackEndEvent) => { this.player.on("end", async (data: TrackEndEvent) => {
try {
if (this.isDead) return; if (this.isDead) return;
if (data.reason === "replaced") return; if (data.reason === "replaced") return;
// 방금 끝난 곡을 대기열에서 지우면서 lastPlayedTrack에 저장 // 방금 끝난 곡을 대기열에서 지우면서 lastPlayedTrack에 저장
@@ -48,11 +55,19 @@ export class GuildPlayer {
} else { } else {
this.end(); this.end();
} }
} catch (err) {
Logger.error(`[GuildPlayer] end 이벤트 <20><>리 중 에러: ${String(err)}`);
}
}); });
this.player.on("closed", () => { this.player.on("closed", () => {
if (this.isDead) return; if (this.isDead) return;
Logger.info(`[GuildPlayer] 음성 연결이 끊어졌습니다. 재접속을 대기합니다...`); Logger.info(`[GuildPlayer] 음성 연결이 끊어졌습니다. 재접속을 대기합니다...`);
setTimeout(() => { // 이전 closed 타이머가 있으면 제거
if (this.closedTimer !== undefined) {
clearTimeout(this.closedTimer);
}
this.closedTimer = setTimeout(() => {
this.closedTimer = undefined;
if (this.isDead) return; if (this.isDead) return;
// 5초가 지났는데도 연결이 복구되지 않았을 때만 방을 나갑니다. // 5초가 지났는데도 연결이 복구되지 않았을 때만 방을 나갑니다.
@@ -62,15 +77,7 @@ export class GuildPlayer {
return this.delete(); return this.delete();
} }
/** // (1 = CONNECTED, Shoukaku 버전에 따라 연결 상태 체크가 다를 수 있으<EC9E88><EC9CBC> 안전하게 확인)
* declare enum State {
* CONNECTING = 0,
* CONNECTED = 1,
* DISCONNECTING = 2,
* DISCONNECTED = 3
* }
*/
// (1 = CONNECTED, Shoukaku 버전에 따라 연결 상태 체크가 다를 수 있으니 안전하게 확인)
if (this.player && this.player.node.state !== 1) { if (this.player && this.player.node.state !== 1) {
Logger.warn(`[GuildPlayer] 연결 복구 실패. 봇을 퇴장시킵니다.`); Logger.warn(`[GuildPlayer] 연결 복구 실패. 봇을 퇴장시킵니다.`);
return this.delete(); return this.delete();
@@ -79,12 +86,20 @@ export class GuildPlayer {
}); });
this.player.on("exception", async (data) => { this.player.on("exception", async (data) => {
try {
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}`); Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}`);
await this.errMsg("유튜브 차단 또는 재생 오류로 인해 이 곡을 건너뜁니다."); await this.errMsg("유튜브 차단 또는 재생 오류로 인해 이 곡을 건너뜁니다.");
} catch (err) {
Logger.error(`[GuildPlayer] exception 이벤트 처리 중 에러: ${String(err)}`);
}
}); });
this.player.on("stuck", async (data) => { this.player.on("stuck", async (data) => {
try {
Logger.error(`[Lavalink] 곡 로딩 멈춤(Stuck) 발생: ${data.thresholdMs}ms 초과`); Logger.error(`[Lavalink] 곡 로딩 멈춤(Stuck) 발생: ${data.thresholdMs}ms 초과`);
await this.errMsg("음원 로딩이 멈췄습니다. 다음 곡으로 넘어갑니다."); await this.errMsg("음원 로딩이 멈췄습니다. 다음 곡으로 넘어갑니다.");
} catch (err) {
Logger.error(`[GuildPlayer] stuck 이벤트 처리 중 에러: ${String(err)}`);
}
}); });
} }
@@ -214,16 +229,31 @@ export class GuildPlayer {
this.end(); this.end();
return; 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, "자동재생"); this.addTracks(tracks, "자동재생");
} }
public end() { private clearAllTimers() {
if (this.errorTimer !== undefined) { if (this.errorTimer !== undefined) {
clearTimeout(this.errorTimer); clearTimeout(this.errorTimer);
this.errorTimer = undefined; 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 = setTimeout(() => {
this.endTimer = undefined; this.endTimer = undefined;
this.delete(true); this.delete(true);
@@ -238,7 +268,10 @@ export class GuildPlayer {
if (this.isDead) return; if (this.isDead) return;
if (!afterEnd) this.end(); if (!afterEnd) this.end();
this.isDead = true; 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.delPlayer(this.guild.id);
lavalinkManager.shoukaku.leaveVoiceChannel(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 { readdirSync } from "node:fs";
import { Command } from "../types/Command"; import { Command } from "../types/Command";
import { COMMAND_PATH, COMMANDS_PATH } from "../utils/Config"; import { COMMAND_PATH, COMMANDS_PATH } from "../utils/Config";
import { Logger } from "../utils/Logger";
export class Handler { export class Handler {
public commands: Collection<string, Command> = new Collection(); 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 commandName = interaction.commandName;
const command = this.commands.get(commandName); const command = this.commands.get(commandName);
if (!command) return; 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 { Config } from "../utils/Config";
import { Logger } from "../utils/Logger"; import { Logger } from "../utils/Logger";
import { parseLink } from "../utils/music/Url"; import { parseLink } from "../utils/music/Url";
import { shuffle } from "../utils/Shuffle"; import { shuffle } from "../utils/shuffle";
import { Spotify } from "../utils/api/Spotify"; import { Spotify } from "../utils/api/Spotify";
import { YoutubeMusic } from "../utils/api/YoutubeMusic"; import { YoutubeMusic } from "../utils/api/YoutubeMusic";

View File

@@ -148,7 +148,9 @@ class RedisClientClass {
const numIndex = Number(data.index); const numIndex = Number(data.index);
if (isNaN(numIndex)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "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 < 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보다 클수 없습니다." })); // 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); const [removedTrack] = context.player.queue.splice(numIndex + 1, 1);
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, removedTrack })); await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
context.player.setMsg(); 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를 찾을수 없습니다." })); 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); const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return; 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 duration = context.player.nowTrack?.info.length || 0; const duration = context.player.nowTrack.info.length || 0;
const numSeek = Number(data.seek); const numSeek = Number(data.seek);
if (isNaN(numSeek)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "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 < 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을 찾을수 없습니다." })); 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); const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return; 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); const numVolume = Number(data.volume);
if (isNaN(numVolume)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "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 < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." }));

View File

@@ -4,6 +4,7 @@ import { Command } from "../types/Command";
import { clearAllMsg } from "../utils/music/Utils"; import { clearAllMsg } from "../utils/music/Utils";
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config"; import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
import { DB } from "../utils/Database"; import { DB } from "../utils/Database";
import { Logger } from "../utils/Logger";
/** channel 명령어 */ /** channel 명령어 */
export default class implements Command { export default class implements Command {
@@ -100,7 +101,7 @@ export async function channelRegister(guild: Guild | null, channelId: string | n
components: [ getButtons() ], components: [ getButtons() ],
files: [ default_image ], files: [ default_image ],
}).catch((err) => { }).catch((err) => {
console.error(err); Logger.error(`[Channel] 메세지 생성 실패: ${String(err)}`);
return null; return null;
}); });
if (!msg) return client.mkembed({ if (!msg) return client.mkembed({

View File

@@ -72,7 +72,7 @@ export async function channelJoin(guild: Guild | null, voiceChannelId: string |
}) }; }) };
let player = lavalinkManager.getPlayer(guild.id); let player = lavalinkManager.getPlayer(guild.id);
if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId} 참가중입니다.` }), player }; if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId}> 참가중입니다.` }), player };
player = new GuildPlayer( player = new GuildPlayer(
guild, guild,
await lavalinkManager.shoukaku.joinVoiceChannel({ await lavalinkManager.shoukaku.joinVoiceChannel({

View File

@@ -1,10 +1,14 @@
import { Interaction, MessageFlags } from "discord.js"; import { Interaction, MessageFlags } from "discord.js";
import { handler } from "../index"; import { handler } from "../index";
import { buttonInteraction } from "../utils/music/Button"; import { buttonInteraction } from "../utils/music/Button";
import { Logger } from "../utils/Logger";
export const interactionCreate = async (interaction: Interaction) => { export const interactionCreate = async (interaction: Interaction) => {
try {
if (interaction.isStringSelectMenu()) { if (interaction.isStringSelectMenu()) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {}); await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => {
Logger.warn(`[Interaction] SelectMenu deferReply 실패: ${String(err)}`);
});
const commandName = interaction.customId; const commandName = interaction.customId;
const args = interaction.values; const args = interaction.values;
const command = handler.commands.get(commandName); const command = handler.commands.get(commandName);
@@ -17,7 +21,9 @@ export const interactionCreate = async (interaction: Interaction) => {
if (args[0] === "music") return buttonInteraction(interaction, args.slice(1)); if (args[0] === "music") return buttonInteraction(interaction, args.slice(1));
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {}); await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => {
Logger.warn(`[Interaction] Button deferReply 실패: ${String(err)}`);
});
const key = args.shift(); const key = args.shift();
if (!key) return; if (!key) return;
@@ -31,6 +37,11 @@ export const interactionCreate = async (interaction: Interaction) => {
* 명령어 친사람만 보이게 설정 * 명령어 친사람만 보이게 설정
* flags: MessageFlags.Ephemeral * flags: MessageFlags.Ephemeral
*/ */
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {}); await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => {
handler.runCommand(interaction); Logger.warn(`[Interaction] Command deferReply 실패: ${String(err)}`);
});
await handler.runCommand(interaction);
} catch (err) {
Logger.error(`[Interaction] 처리 중 에러: ${String(err)}`);
}
} }

View File

@@ -1,5 +1,3 @@
// TODO: 음성 상태 변경 이벤트 핸들러 (추후 구현)
// import { VoiceState } from "discord.js"; // import { VoiceState } from "discord.js";
// import { client } from "../index"; // export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise<void> => {}
// export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise<void> => {
// }

View File

@@ -1,4 +1,4 @@
import { ButtonInteraction, ChatInputApplicationCommandData, ChatInputChatInputCommandInteraction, Message, StringSelectMenuInteraction } from "discord.js"; import { ButtonInteraction, ChatInputApplicationCommandData, ChatInputCommandInteraction, Message, StringSelectMenuInteraction } from "discord.js";
export interface Command { export interface Command {
/** 메세지 이름 */ /** 메세지 이름 */
@@ -13,9 +13,9 @@ export interface Command {
* 등록 메타: JSON 변환된 바디 * 등록 메타: JSON 변환된 바디
* (빌드 시 toJSON()해서 REST 등록에 사용) * (빌드 시 toJSON()해서 REST 등록에 사용)
*/ */
metaData: RESTPostAPIChatInputApplicationCommandsJSONBody; metaData: ChatInputApplicationCommandData;
slashRun?: (args: ChatInputChatInputCommandInteraction) => Promise<void>; slashRun?: (args: ChatInputCommandInteraction) => Promise<void>;
messageRun?: (message: Message, args: string[]) => Promise<void>; messageRun?: (message: Message, args: string[]) => Promise<void>;
menuRun?: (interaction: StringSelectMenuInteraction, args: string[]) => Promise<void>; menuRun?: (interaction: StringSelectMenuInteraction, args: string[]) => Promise<void>;
buttonRun?: (interaction: ButtonInteraction, args: string[]) => Promise<void>; buttonRun?: (interaction: ButtonInteraction, args: string[]) => Promise<void>;

View File

@@ -69,6 +69,8 @@ export const Config = {
return this._youtube_cookie; return this._youtube_cookie;
}, },
proxyUrl: process.env.PROXY_URL?.trim() || "",
_redis: { _redis: {
state: process.env.REDIS?.trim()?.toLocaleLowerCase() === "true", state: process.env.REDIS?.trim()?.toLocaleLowerCase() === "true",
host: process.env.REDIS_HOST?.trim(), host: process.env.REDIS_HOST?.trim(),

View File

@@ -7,12 +7,18 @@ import { Logger } from "./Logger";
const database = new Database(Config.dbPath); const database = new Database(Config.dbPath);
const schemaPath = join(process.cwd(), "db/schema.sql"); const schemaPath = join(__dirname, "../../db/schema.sql");
const schema = readFileSync(schemaPath, "utf-8"); const schema = readFileSync(schemaPath, "utf-8");
database.exec(schema); database.exec(schema);
Logger.ready("DB 활성화!"); Logger.ready("DB 활성화!");
// 허용되는 guild 테이블 컬럼 화이트리스<EBA6AC><EC8AA4><EFBFBD>
const GUILD_COLUMNS = new Set(["id", "name", "channel_id", "msg_id", "options"]);
const filterKeys = (keys: string[], whitelist: Set<string>) =>
keys.filter(k => whitelist.has(k));
const stmt = { const stmt = {
guild: { guild: {
// 전체 // 전체
@@ -21,7 +27,7 @@ const stmt = {
get: database.prepare("SELECT * FROM guilds WHERE ID = ?"), get: database.prepare("SELECT * FROM guilds WHERE ID = ?"),
// 추가 // 추가
insert: (data: GuildRow) => { insert: (data: GuildRow) => {
const keys = Object.keys(data); const keys = filterKeys(Object.keys(data), GUILD_COLUMNS);
if (keys.length === 0) throw new Error("insert: 키1개는 있어야함"); if (keys.length === 0) throw new Error("insert: 키1개는 있어야함");
return database.prepare(`INSERT INTO guilds (${ return database.prepare(`INSERT INTO guilds (${
keys.map(k => `"${k}"`).join(", ") keys.map(k => `"${k}"`).join(", ")
@@ -31,35 +37,13 @@ const stmt = {
}, },
// 수정 // 수정
update: (data: GuildRow) => { update: (data: GuildRow) => {
const keys = Object.keys(data).filter(k => k !== "id"); const keys = filterKeys(Object.keys(data), GUILD_COLUMNS).filter(k => k !== "id");
if (keys.length === 0) throw new Error("update: 키1개는 있어야함"); if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
return database.prepare(`UPDATE guilds SET ${ return database.prepare(`UPDATE guilds SET ${
keys.map(k => `${k} = @${k}`).join(", ") keys.map(k => `${k} = @${k}`).join(", ")
} WHERE id = @id`).run(data); } WHERE id = @id`).run(data);
}, },
}, },
// user: {
// // 가져오기
// get: database.prepare("SELECT * FROM users WHERE guild_id = ? AND id = ?"),
// // 추가
// insert: (data: UserType) => {
// const keys = Object.keys(data);
// if (keys.length === 0) throw new Error("insert: 키1개는 있어야함");
// return database.prepare(`INSERT INTO users (${
// keys.map(k => `"${k}"`).join(", ")
// }) VALUES (${
// keys.map(k => `@${k}`).join(", ")
// })`).run(data);
// },
// // 수정
// update: (data: UserType) => {
// const keys = Object.keys(data).filter(k => k !== "guild_id" && k !== "id");
// if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
// return database.prepare(`UPDATE users SET ${
// keys.map(k => `${k} = @${k}`).join(", ")
// } WHERE guild_id = @guild_id AND id = @id`).run(data);
// },
// },
}; };
export const DB = { export const DB = {
@@ -91,27 +75,4 @@ export const DB = {
} }
}, },
}, },
// user: {
// get(guildId: string, id: string) {
// return stmt.user.get.get(guildId, id) as UserType | undefined;
// },
// set(data: UserType) {
// try {
// stmt.user.insert(data);
// return true;
// } catch (err) {
// Logger.error(String(err));
// return false;
// }
// },
// update(data: UserType) {
// try {
// stmt.user.update(data);
// return true;
// } catch (err) {
// Logger.error(String(err));
// return false;
// }
// },
// },
}; };

View File

@@ -8,7 +8,7 @@ const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? "";
const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"; const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
const SPOTIFY_API_URL = "https://api.spotify.com/v1"; const SPOTIFY_API_URL = "https://api.spotify.com/v1";
const TOKENR_URL = "http://192.168.10.5:8075/api/token"; const TOKENR_URL = process.env.SPOTIFY_TOKENER_URL?.trim() || "http://192.168.10.5:8075/api/token";
const searchCache = new Map<string, string>(); const searchCache = new Map<string, string>();

View File

@@ -3,10 +3,11 @@ import crypto from "node:crypto";
import { Cookies } from "../../types/Youtube_Cookie"; import { Cookies } from "../../types/Youtube_Cookie";
import { Config } from "../Config"; import { Config } from "../Config";
import { SongItem } from "../../types/Track"; import { SongItem } from "../../types/Track";
import { Logger } from "../Logger";
const customPREF = "tz=Asia.Seoul&hl=ko&gl=KR&last_quality=1080"; const customPREF = "tz=Asia.Seoul&hl=ko&gl=KR&last_quality=1080";
export const ORIGIN = "https://music.youtube.com"; export const ORIGIN = "https://music.youtube.com";
const proxy = new ProxyAgent('http://192.168.10.4:3128'); const proxy = Config.proxyUrl ? new ProxyAgent(Config.proxyUrl) : undefined;
const searchCache = new Map<string, string>(); const searchCache = new Map<string, string>();
// 🌟 클래스 외부에 둘 상수 및 유틸리티 함수들 (내부에서만 사용됨) // 🌟 클래스 외부에 둘 상수 및 유틸리티 함수들 (내부에서만 사용됨)
@@ -56,7 +57,7 @@ export const YoutubeMusic = {
const missing = keys.filter((k) => !(k in cookies) && !(blocks ?? []).includes(k)); const missing = keys.filter((k) => !(k in cookies) && !(blocks ?? []).includes(k));
if (missing.length > 0) { if (missing.length > 0) {
console.log("현재 입력된 쿠키 키 목록:", Object.keys(cookies)); Logger.warn(`현재 입력된 쿠키 키 목록: ${Object.keys(cookies).join(", ")}`);
throw new Error(`❌ 필수 인증 쿠키가 누락되었습니다: ${missing.join(", ")}`); throw new Error(`❌ 필수 인증 쿠키가 누락되었습니다: ${missing.join(", ")}`);
} }
@@ -82,7 +83,7 @@ export const YoutubeMusic = {
* 완벽한 쿠키 인증과 서명(SAPISIDHASH)을 사용하여 유튜브 뮤직 검색을 수행합니다. * 완벽한 쿠키 인증과 서명(SAPISIDHASH)을 사용하여 유튜브 뮤직 검색을 수행합니다.
*/ */
async getSearchFull(query: string): Promise<SongItem[]> { async getSearchFull(query: string): Promise<SongItem[]> {
console.log(`🔍 [Auth-Cookie Engine] "${query}" 데이터 추출 중 (썸네일, 재생시간 포함)...`); Logger.log(`🔍 [Auth-Cookie Engine] "${query}" 데이터 추출 중 (썸네일, 재생시간 포함)...`);
const url = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false"; const url = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false";
@@ -109,7 +110,7 @@ export const YoutubeMusic = {
query: query, query: query,
params: "EgWKAQIIAWoOEAMQBBAQEAkQFRAKEBE=" params: "EgWKAQIIAWoOEAMQBBAQEAkQFRAKEBE="
}), }),
dispatcher: proxy ...(proxy ? { dispatcher: proxy } : {})
}); });
const data: any = await response.json(); const data: any = await response.json();
@@ -200,9 +201,9 @@ export const YoutubeMusic = {
} }
} }
return results || []; // 배열이 비어있을 경우 안전하게 null 반환 return results;
} catch (error) { } catch (error) {
console.error("❌ getSearchFull 실행 중 에러:", error); Logger.error(`❌ getSearchFull 실행 중 에러: ${String(error)}`);
return []; return [];
} }
}, },

View File

@@ -3,6 +3,7 @@ import { lavalinkManager } from "../../index";
import { checkTextChannelAndMsg, getTextChannelAndMsg } from "./Channel"; import { checkTextChannelAndMsg, getTextChannelAndMsg } from "./Channel";
import { default_content, default_embed, default_image, getButtons } from "./Config"; import { default_content, default_embed, default_image, getButtons } from "./Config";
import { DB } from "../Database"; import { DB } from "../Database";
import { Logger } from "../Logger";
export const buttonInteraction = (interaction: ButtonInteraction, args: string[]) => { export const buttonInteraction = (interaction: ButtonInteraction, args: string[]) => {
if (!interaction.guild) return; if (!interaction.guild) return;
@@ -16,7 +17,9 @@ export const buttonInteraction = (interaction: ButtonInteraction, args: string[]
} else { } else {
if (args[0] === "recommend") buttonRecommend(interaction.guild); if (args[0] === "recommend") buttonRecommend(interaction.guild);
} }
return interaction.deferUpdate().catch(() => {}); return interaction.deferUpdate().catch((err) => {
Logger.warn(`[Button] deferUpdate 실패: ${String(err)}`);
});
} }
const buttonRecommend = async (guild: Guild) => { const buttonRecommend = async (guild: Guild) => {
@@ -33,7 +36,7 @@ const buttonRecommend = async (guild: Guild) => {
components: [ getButtons() ], components: [ getButtons() ],
files: [ default_image ], files: [ default_image ],
}).catch((err) => { }).catch((err) => {
console.error(err); Logger.error(`[Button] 메세지 수정 실패: ${String(err)}`);
return null; return null;
}); });
} }

View File

@@ -6,15 +6,19 @@ import { clearAllMsg } from "./Utils";
import { client } from "../../index"; import { client } from "../../index";
export const getGuildById = async (guildId: string): Promise<Guild | null> => { export const getGuildById = async (guildId: string): Promise<Guild | null> => {
const guild = client.guilds.cache.get(guildId)?.fetch(); const guild = await client.guilds.cache.get(guildId)?.fetch().catch(() => undefined);
if (!guild) return null; if (!guild) return null;
return guild; return guild;
} }
export const getMemberById = async (guild: Guild, userId: string): Promise<GuildMember | null> => { export const getMemberById = async (guild: Guild, userId: string): Promise<GuildMember | null> => {
const member = await guild.members.cache.get(userId)?.fetch(true); try {
if (!member) return null; const cached = guild.members.cache.get(userId);
return member; if (!cached) return null;
return await cached.fetch(true);
} catch {
return null;
}
} }
export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => { export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => {
@@ -25,9 +29,14 @@ export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => {
export const getVoiceChannelById = async (guild: Guild, userId: string): Promise<VoiceChannel | null> => { export const getVoiceChannelById = async (guild: Guild, userId: string): Promise<VoiceChannel | null> => {
if (!guild) return null; if (!guild) return null;
const member = await guild.members.cache.get(userId)?.fetch(true); try {
if (!member) return null; const cached = guild.members.cache.get(userId);
if (!cached) return null;
const member = await cached.fetch(true);
return getVoiceChannel(member); return getVoiceChannel(member);
} catch {
return null;
}
} }
export const getTextChannelAndMsg = async (guild: Guild): Promise<{ channel?: TextChannel; msg?: Message; reason?: string; }> => { export const getTextChannelAndMsg = async (guild: Guild): Promise<{ channel?: TextChannel; msg?: Message; reason?: string; }> => {

View File

@@ -1,15 +1,8 @@
export const fshuffle = (list: any[]): any[] => { /** Fisher-Yates 셔플 (in-place) */
var i, j, x; export const shuffle = <T>(list: T[]): T[] => {
for (i=list.length; i; i-=1) { for (let i = list.length - 1; i > 0; i--) {
j = Math.floor(Math.random()*i); const j = Math.floor(Math.random() * (i + 1));
x = list[i-1]; [list[i], list[j]] = [list[j], list[i]];
list[i-1] = list[j];
list[j] = x;
} }
return list; return list;
} }
export const shuffle = (list: any[]): any[] => {
for (let z=0; z<5; z++) list = fshuffle(list);
return list;
}