지금까지 내용 커밋

This commit is contained in:
2026-04-08 12:59:45 +09:00
commit b0dae31cb9
68 changed files with 12083 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
import { Client, ClientEvents, ColorResolvable, EmbedBuilder, EmbedField, GatewayIntentBits, Message } from "discord.js";
import { Config } from "../utils/Config";
export class BotClient extends Client {
public prefix = Config.prefix;
public color: ColorResolvable = Config.embedColor;
public constructor() {
super({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildVoiceStates,
],
});
}
public async start() {
this.login(Config.token);
}
/**
* 이벤트 핸들러 등록
*
* 지정한 이벤트가 발생했을때 해당 핸들러를 호출함
* * 'func'의 내용은 기본적으로 'client.on'을 따름
* * 'extra'를 입력할 경우 추가되어 같이 전달
*
* @example
* client.onEvent('ready', (client, info) => {
* Logger.ready(client?.user.username, '봇이 준비되었습니다.', info) // 출력: OOO 봇이 준비되었습니다. 추가 정보
* }, ['추가 정보']);
*
* @param event 이벤트명
* @param func 이벤트 핸들러 함수
* @param extra 추가로 전달할 목록
*/
public readonly onEvent = (event: keyof ClientEvents, func: Function, ...extra: any[]) => this.on(event, (...args) => func(...args, ...extra));
public mkembed(data: {
title?: string;
description?: string;
url?: string;
image?: string;
thumbnail?: string;
author?: { name: string, iconURL?: string, url?: string };
addFields?: EmbedField[];
timestamp?: number | Date | undefined | null;
footer?: { text: string, iconURL?: string };
color?: ColorResolvable;
}): EmbedBuilder {
if (!data.color) data.color = this.color;
const embed = new EmbedBuilder();
if (data.title) embed.setTitle(data.title);
if (data.description) embed.setDescription(data.description);
if (data.url) embed.setURL(data.url);
if (data.image) embed.setImage(data.image);
if (data.thumbnail) embed.setThumbnail(data.thumbnail);
if (data.author) embed.setAuthor({ name: data.author.name, iconURL: data.author.iconURL, url: data.author.url });
if (data.addFields) embed.addFields(data.addFields);
if (data.timestamp) embed.setTimestamp(data.timestamp);
if (data.footer) embed.setFooter({ text: data.footer.text, iconURL: data.footer.iconURL });
if (data.color) embed.setColor(data.color);
return embed;
}
public msgDelete(message: Message, time: number, customTime?: boolean): void {
setTimeout(async () => {
try {
const msg = await message.fetch(true).catch(() => undefined);
if (msg?.deletable) msg.delete().catch(() => {});
} catch {};
}, Math.max(100, time * (customTime ? 1 : 6000)));
}
}

View File

@@ -0,0 +1,278 @@
import { Guild, Message, TextChannel } from "discord.js";
import { LoadType, Player, Track, TrackEndEvent } from "shoukaku";
import { client, lavalinkManager } from "../index";
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 { checkTextChannelAndMsg } from "../utils/music/Channel";
const DelayAfterErrMs = 1000 * 5;
const idleEndTime = 1000 * 60 * 10;
type QueueTrack = Track & { userId: string; };
export class GuildPlayer {
private isDead = false;
private canRecommend = true;
private _GDB: GuildType | undefined;
private lastPlayedTrack: QueueTrack | undefined;
public queue: QueueTrack[] = [];
private errorTimer: NodeJS.Timeout | undefined;
private endTimer: NodeJS.Timeout | undefined;
constructor(
public guild: Guild,
public player: Player,
public voiceChannelId: string,
public textChannel: TextChannel,
public msg: Message,
) {
this.player.setGlobalVolume(50);
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();
}
});
this.player.on("closed", () => {
if (this.isDead) return;
this.delete();
});
}
private get GDB() {
if (!this._GDB) this._GDB = DB.guild.get(this.guild.id);
return this._GDB;
}
public get isPlaying(): boolean {
return this.player.track !== null;
}
public get nowTrack(): QueueTrack | null {
return this.queue[0] ?? null;
}
public get isPaused(): boolean {
return this.player.paused;
}
public get isRecommend(): boolean {
return this.GDB?.options.recommend ?? false;
}
public async addTrack(track: Track, userId: string) {
this.queue.push({ ...track, userId });
if (this.queue.length === 1) {
// 큐가 비어있으면 바로 재생
await this.playNext();
} else {
// 재생목록 추가되었을때
this.setMsg();
}
}
public async addTracks(tracks: Track[], userId: string) {
const first = this.queue.length === 0;
for (const track of tracks) this.queue.push({ ...track, userId });
if (first) {
// 큐가 비어있으면 바로 재생
await this.playNext();
} else {
// 재생목록 추가되었을때
this.setMsg();
}
}
public async playNext() {
const track = this.queue[0];
if (!track) return this.end();
await this.player.playTrack({ track: { encoded: track.encoded } });
// 재생관련
this.setMsg();
}
public async setPause() {
if (this.isPaused) {
await this.player.setPaused(false);
this.setMsg();
} else if (this.isPlaying) {
await this.player.setPaused(true);
this.setMsg();
}
}
public stop() {
this.queue = [];
this.canRecommend = false;
this.player.stopTrack();
}
public skip() {
// 곡 스킵했을때
this.player.stopTrack();
}
public setShuffle() {
let nowQueue = this.queue.slice(1);
nowQueue = shuffle(nowQueue);
this.queue = [this.queue[0], ...nowQueue];
this.setMsg();
}
public setRecommend() {
if (!this.GDB) return;
this.GDB.options.recommend = !this.GDB.options.recommend;
DB.guild.update(this.GDB);
this.setMsg();
}
public seek(num: number) {
if (!this.isPlaying) return;
if (!this.nowTrack) return;
this.player.seekTo(num);
}
private async autoPlay() {
if (!this.lastPlayedTrack) return;
const source = this.lastPlayedTrack.info.sourceName;
const trackId = this.lastPlayedTrack.info.identifier;
let result;
if (source === "spotify") {
result = await this.player.node.rest.resolve(`sprec:seed_tracks=${trackId}&limit=11`);
} else {
result = await this.player.node.rest.resolve(`https://music.youtube.com/watch?v=${trackId}&list=RD${trackId}`);
}
let tracks: Track[] = [];
if (result?.loadType === LoadType.PLAYLIST) {
tracks = result.data.tracks;
} else if (result?.loadType === LoadType.SEARCH || Array.isArray(result?.data)) {
tracks = Array.isArray(result.data) ? result.data : [];
} else if (result?.loadType === LoadType.TRACK) {
tracks = [ result.data ];
}
if (tracks.length === 0) {
this.end();
return;
}
if (tracks[0].info.identifier === trackId) tracks = tracks.slice(1);
this.addTracks(tracks, "자동재생");
}
public end() {
if (this.errorTimer !== undefined) {
clearTimeout(this.errorTimer);
this.errorTimer = undefined;
}
if (this.endTimer !== undefined) clearTimeout(this.endTimer);
this.endTimer = setTimeout(() => {
this.endTimer = undefined;
this.delete(true);
}, idleEndTime);
this.lastPlayedTrack = undefined;
this.queue = [];
this.canRecommend = true;
this.setMsg();
}
public delete(afterEnd?: boolean) {
if (this.isDead) return;
if (!afterEnd) this.end();
this.isDead = true;
this.player.destroy().catch(() => {});
lavalinkManager.delPlayer(this.guild.id);
lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id);
}
private async setMsg() {
const { channel, msg, check } = await checkTextChannelAndMsg(this.guild, this.textChannel, this.msg);
if (!check) return;
this.textChannel = channel;
this.msg = msg;
if (!this.msg) return;
// content
let content = default_content;
let realQueue = this.queue.slice(1);
if (realQueue.length > 0) {
content = "__**대기열 목록:**__\n";
let list: string[] = [];
let nowLength = content.length + 50; // 50자 여유두기
for (let i=0; i<realQueue.length; i++) {
let track = realQueue[i];
let text = `${
(i+1).toString().padStart(realQueue.length.toString().length, "0")
}\\. ${
track.info.author.replace(" - Topic","")
} - ${
track.info.title
} [${
timeFormat(track.info.length)
}] ~ ${
track.userId === "자동재생" ? "자동재생" : `<@${track.userId}>`
}`;
if (nowLength + text.length > 2000) {
content += `+ ${realQueue.length-list.length}곡\n`;
break;
}
nowLength += text.length;
list.push(text);
}
content += list.reverse().join("\n");
}
// embed
const embed = (this.isPlaying || this.isPaused) && !!this.nowTrack
? client.mkembed({
title: `**[${timeFormat(this.nowTrack.info.length)}] ${this.nowTrack.info.author.replace(" - Topic","")} - ${this.nowTrack.info.title}**`,
description: `노래 요청자: ${this.nowTrack.userId === "자동재생" ? "자동재생" : `<@${this.nowTrack.userId}>`}`,
image: this.nowTrack.info.artworkUrl,
url: this.nowTrack.info.author.includes(" - Topic") ? this.nowTrack.info.uri?.replace("www.youtube","music.youtube") : this.nowTrack.info.uri,
footer: { text: `대기열: ${realQueue.length}개 | 볼륨: ${this.player.volume}${
this.isRecommend ? " | 자동재생: 활성화" : ""
}${
this.isPaused ? " | 노래가 일시중지 되었습니다." : ""
}` }
})
: default_embed(this.guild.id);
this.msg.edit({
content: content,
embeds: [ embed ],
files: embed.data.image?.url === default_embed(this.guild.id).data.image?.url ? [ default_image ] : [],
components: [ getButtons(
this.isPlaying || this.isPaused,
this.isPaused,
realQueue.length > 1,
) ]
});
}
public async errMsg(reason: string) {
if (!this.msg) return null;
await this.msg.edit({
content: this.msg.content,
embeds: [ client.mkembed({
title: "오류발생!!",
description: reason,
color: "DarkRed",
}) ],
files: [],
components: [ getButtons(
false,
false,
this.queue.length > 1,
) ]
});
this.errorTimer = setTimeout(() => {
this.errorTimer = undefined;
this.playNext();
}, DelayAfterErrMs);
return null;
}
}

View File

@@ -0,0 +1,25 @@
import { ChatInputCommandInteraction, Collection } from "discord.js";
import { readdirSync } from "node:fs";
import { Command } from "../types/Command";
import { COMMAND_PATH, COMMANDS_PATH } from "../utils/Config";
export class Handler {
public commands: Collection<string, Command> = new Collection();
public coolDown: Map<string, number> = new Map();
public constructor() {
const commandFiles = readdirSync(COMMANDS_PATH);
for (const commandFile of commandFiles) {
const command = new (require(COMMAND_PATH(commandFile)).default)() as Command;
this.commands.set(command.metaData.name, command);
}
}
public runCommand(interaction: ChatInputCommandInteraction) {
const commandName = interaction.commandName;
const command = this.commands.get(commandName);
if (!command) return;
if (command.slashRun) command.slashRun(interaction);
}
}

View File

@@ -0,0 +1,83 @@
import { Connectors, LoadType, Shoukaku } from "shoukaku";
import { Client } from "discord.js";
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 { Spotify } from "../utils/api/Spotify";
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
export class LavalinkManager {
public shoukaku: Shoukaku;
private players: Map<string, GuildPlayer> = new Map();
constructor(readonly client: Client) {
this.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), [{
name: "tkrmagid-Lavalink-Server",
url: `${Config.lavalink.host}:${Config.lavalink.port}`,
auth: Config.lavalink.pw,
secure: false,
}], {
moveOnDisconnect: true,
reconnectTries: 3,
});
this.shoukaku.on("ready", (name) => {
Logger.ready(`[LavalinkManager] 노드 [${name}] 연결 완료!`);
});
this.shoukaku.on("error", (name, err) => {
Logger.error(`[LavalinkManager] 노드 [${name}] 에러: ${String(err)}`);
});
}
getPlayer(guildId: string) {
return this.players.get(guildId);
}
addPlayer(guildId: string, player: GuildPlayer) {
this.players.set(guildId, player);
}
delPlayer(guildId: string) {
this.players.delete(guildId);
}
async search(guildId: string, query: string, userId: string, player?: GuildPlayer) {
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
if (!node) {
this.delPlayer(guildId);
throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
}
player = player ?? this.getPlayer(guildId);
if (!player) return;
const { isUrl, text, flags } = parseLink(query.trim());
let searchText = isUrl ? text : (await Spotify.getSearchUrl(text)) ?? (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
if (flags.has('y')) searchText = (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
if (searchText.startsWith("ytsearch") && !flags.has('o')) searchText += " Topic";
const result = await node.rest.resolve(searchText);
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`);
// 노래 못찾았을때
player.errMsg(`노래를 찾을수 없습니다.`);
return;
}
if (result.loadType === LoadType.PLAYLIST) {
player.addTracks(
flags?.has("s") ? shuffle(result.data.tracks) : result.data.tracks,
userId
);
return;
}
if (result.loadType === LoadType.TRACK) {
player.addTrack(result.data, userId);
return;
}
if (result.loadType === LoadType.SEARCH) {
if (result.data.length === 0) {
player.errMsg(`노래를 찾을수 없습니다.`);
return;
}
player.addTrack(result.data[0], userId);
return;
}
}
}

View File

@@ -0,0 +1,77 @@
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";
export class RedisClient {
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) => {
if (ch !== "site-bot") return;
Logger.log(`[Redis Sub] [Message] 수신: {\n 채널: ${ch}\n 내용: ${msg}\n}`);
try {
const data = JSON.parse(msg) as { action: "search"; requestId: string; [key: string]: any; };
if (data.action === "search") {
const resultKey = `search:result:${data.requestId}`;
const results = await Spotify.getSearchFull(data.query) ?? await YoutubeMusic.getSearchFull(data.query) ?? [];
await this.pub.setex(resultKey, 60, JSON.stringify(results));
Logger.log(`[Redis Pub] [setex] 결과 저장: (${resultKey})`);
}
} 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}`);
}
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);
}
}