This commit is contained in:
2026-05-26 14:15:09 +09:00
parent 55d402f606
commit d6b36c43c2
33 changed files with 1496 additions and 0 deletions

73
src/classes/BotClient.ts Normal file
View File

@@ -0,0 +1,73 @@
import { Config } from "../utils/Config";
import { Client, ClientEvents, ColorResolvable, EmbedBuilder, EmbedField, GatewayIntentBits, Message } from "discord.js";
export class BotClient extends Client {
public prefix = Config.prefix;
public color: ColorResolvable = "Blue";
public constructor() {
super({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildVoiceStates,
],
});
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)));
}
}

25
src/classes/Handler.ts Normal file
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,172 @@
import { join, dirname } from "node:path";
import { Logger } from "../utils/Logger";
import WebSocket from "ws";
import ffmpeg from "fluent-ffmpeg";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { Readable } from "node:stream";
interface SignatureItem {
name: string[];
url: string;
volume: number;
}
const filePath = join(__dirname, "..", "..", "file");
if (existsSync(filePath)) rmSync(filePath, { recursive: true, force: true });
mkdirSync(filePath, { recursive: true });
export class SignatureClient {
private readonly myClientId = "bot-"+Math.random().toString(36).slice(2, 9);
private readonly host = `192.168.10.5:2967`;
private readonly wsUrl = `ws://${this.host}?clientId=${this.myClientId}`;
private ws = new WebSocket(this.wsUrl);
private signatureList: SignatureItem[] = [];
get list() {
return this.signatureList;
}
get nameSet() {
return new Set(this.list.flatMap(v => v.name));
}
get regex() {
const escaped = [...this.nameSet].map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp(`(${escaped.join("|")})`, "g");
}
constructor() {
this.ws.on("open", () => {
Logger.ready("[WS] 서버 연결 성공");
const requestMessage = JSON.stringify({ type: "GET_LIST" });
this.ws.send(requestMessage);
});
this.ws.on("message", (message: string) => {
try {
const payload = JSON.parse(message);
if (
payload.senderId === "server-sync"
|| payload.senderId !== this.myClientId
) this.init(payload.data);
} catch (err) {
Logger.error(`[WS] 수신 메시지 파싱 오류: ${String(err)}`);
}
});
this.ws.on("close", () => {
Logger.info("[WS] 서버 연결 종료");
});
this.ws.on("error", (error) => {
Logger.error(`[WS] 오류 발생: ${String(error)}`);
});
}
async changeVolume(buffer: Buffer, volume: number) {
try {
const inputStream = Readable.from(buffer);
return await new Promise<Buffer>((res, rej) => {
const chunks: Buffer[] = [];
const outputStream = ffmpeg()
.input(inputStream)
.inputFormat("mp3")
.audioFilter(`volume=${volume/100}`)
.toFormat("mp3")
.on("error", rej)
.pipe();
outputStream.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
outputStream.on("end", () => {
res(Buffer.concat(chunks));
});
outputStream.on("error", rej);
});
} catch (err) {
Logger.error(`[시그니처] 볼륨 조절 오류: ${String(err)}`);
return buffer;
}
}
async init(nowList: SignatureItem[]) {
Logger.info("[시그니처] 파일 검사중...");
// 기존 목록에 없는것
const setList = new Set(this.list.map(v => v.url));
const newItems = nowList.filter(item => !setList.has(item.url));
for (const item of newItems) {
try {
const itemPath = join(filePath, item.url+".mp3");
const dirPath = join(filePath, dirname(item.url+".mp3"));
if (!existsSync(dirPath)) mkdirSync(dirPath, { recursive: true });
let buffer = await this.getFileByUrl(item.url+".mp3");
if (item.volume !== 100) buffer = await this.changeVolume(buffer, item.volume);
writeFileSync(itemPath, buffer);
Logger.info(`[시그니처] ${item.url} 파일 생성`);
} catch (err) {
Logger.error(`[시그니처] ${item.url} 파일 생성 오류: ${String(err)}`);
}
}
// 새로운 목록에 없는것
const setNowList = new Set(nowList.map(v => v.url));
const delItems = this.list.filter(item => !setNowList.has(item.url));
for (const item of delItems) {
try {
const itemPath = join(filePath, item.url+".mp3");
const dirPath = join(filePath, dirname(item.url+".mp3"));
if (existsSync(itemPath)) unlinkSync(itemPath);
Logger.info(`[시그니처] ${item.url} 파일 삭제`);
if (readdirSync(dirPath).length === 0) {
rmdirSync(dirPath);
Logger.info(`[시그니처] ${dirname(item.url + ".mp3")} 폴더 삭제`);
}
} catch (err) {
Logger.error(`[시그니처] ${item.url} 파일 삭제 오류: ${String(err)}`);
}
}
// 볼륨 바뀐거
const volItems = nowList.filter(item => {
const find = this.list.find(item2 => item.url === item2.url);
return find && find.volume !== item.volume;
});
for (const item of volItems) {
try {
const itemPath = join(filePath, item.url+".mp3");
let buffer = await this.getFileByUrl(item.url+".mp3");
if (item.volume !== 100) buffer = await this.changeVolume(buffer, item.volume);
writeFileSync(itemPath, buffer);
Logger.info(`[시그니처] ${item.url} 볼륨조절: ${item.volume}`);
} catch (err) {
Logger.error(`[시그니처] ${item.url} 파일 볼륨조절 오류: ${String(err)}`);
}
}
Logger.info("[시그니처] 파일 검사 완료");
this.signatureList = nowList;
}
async getFileByUrl(url: string): Promise<Buffer> {
try {
const res = await fetch(`http://${this.host}/file/${url}`);
if (!res.ok) throw new Error(`HTTP 오류: ${res.status} ${res.statusText}`);
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return buffer;
} catch (err) {
throw err;
}
}
async getBuffer(name: string): Promise<Buffer | null> {
try {
const data = this.list.find(item => item.name.includes(name));
if (!data) return null;
return readFileSync(join(filePath, data.url+".mp3"));
} catch (err) {
// Logger.error(`파일 불러오기 오류: ${String(err)}`);
return null;
}
}
}

139
src/classes/TTSClient.ts Normal file
View File

@@ -0,0 +1,139 @@
import { client, signature } from "../index";
import { ChannelType, Guild, GuildMember, TextChannel, VoiceBasedChannel, VoiceChannel } from "discord.js";
import { Config } from "../utils/Config";
import { Logger } from "../utils/Logger";
import { VoiceSession } from "./VoiceSession";
import { textToSpeech, VoiceType } from "../utils/tts/Chzzk";
// import { textToSpeech } from "../utils/tts/Google";
const URL_RE = /https?:\/\/[^\s<>"']+/gi;
const DOMAIN_LABELS: { test: (h: string) => boolean; label: string }[] = [
{ test: h => h === "youtu.be" || h.endsWith("youtube.com"), label: "유튜브 주소", },
{ test: h => h.endsWith("twitch.tv"), label: "트위치 주소" },
{ test: h => h.endsWith("steampowered.com"), label: "스팀 주소" },
{ test: h => h.endsWith("naver.com") || h.endsWith("naver.me"), label: "네이버 주소" },
{ test: h => h.endsWith("namu.wiki"), label: "나무위키 주소" },
{ test: h => h.endsWith("google.com") || h.endsWith("google.co.kr"), label: "구글 주소" },
];
const labelForUrl = (url: string): string | null => {
try {
const hostname = new URL(url.replace(/[),.]+$/g,"")).hostname.replace(/^www\./,"");
const find = DOMAIN_LABELS.find(d => d.test(hostname));
return find?.label ?? "주소";
} catch {
return null;
}
}
export class TTSClient {
private map: Map<string, VoiceSession> = new Map();
private maxLength = 300;
private leaveTime = 1000*60*10; // 10분
public findSession(guildId: string) {
return this.map.get(guildId);
}
public getVoiceChannel(member: GuildMember): VoiceChannel | null {
if (member.voice.channel?.type === ChannelType.GuildVoice) return member.voice.channel;
if (member.guild.members.me?.voice.channel?.type === ChannelType.GuildVoice) return member.guild.members.me.voice.channel;
return null;
}
public async tts(channel: TextChannel, member: GuildMember, text: string): Promise<void> {
const voiceChannel = this.getVoiceChannel(member);
if (!voiceChannel) {
channel.send({ embeds: [ client.mkembed({
title: "음성채널 가져오기 오류",
description: "음성채널을 찾을수 없습니다.\n음성채널에 들어가서 사용해주세요.",
color: "DarkRed",
}) ] }).then(m => client.msgDelete(m, 1));
return;
}
if (text.length > this.maxLength) {
channel.send({ embeds: [ client.mkembed({
title: "채팅 가져오기 오류",
description: `최대 ${this.maxLength}자까지 가능합니다.`,
color: "DarkRed",
}) ] }).then(m => client.msgDelete(m, 1));
return;
}
text = this.textEditor(channel.guild, text);
const buf = await this.getSorce(text);
if (!buf) return;
const session = this.getSession(channel.guild, voiceChannel);
session.play(buf);
}
private readonly onSessionEnd = (guildId: string) => {
this.map.delete(guildId);
}
private createSession(guild: Guild, voiceChannel: VoiceBasedChannel) {
const session = new VoiceSession(guild, voiceChannel, this.leaveTime, this.onSessionEnd);
this.map.set(guild.id, session);
return session;
}
private getSession(guild: Guild, voiceChannel: VoiceBasedChannel) {
const session = this.map.get(guild.id) ?? this.createSession(guild, voiceChannel);
session.moveTo(voiceChannel).catch((err) => {
Logger.error("moveTo failed:"+String(err));
});
return session;
}
public endSession(guildId: string) {
const session = this.map.get(guildId);
if (!session) return;
session.sessionEnd();
}
private async getSorce(text: string): Promise<Buffer | null> {
const parts = text.split(signature.regex);
const bufferList: Buffer[] = [];
for (const part of parts) {
if (!part) continue;
if (signature.nameSet.has(part)) {
const buf = await signature.getBuffer(part);
if (buf) {
bufferList.push(buf);
} else {
const buf = await textToSpeech(this.textReplace(part), VoiceType.).catch(() => null);
if (buf) bufferList.push(buf);
}
} else {
const buf = await textToSpeech(this.textReplace(part), VoiceType.).catch(() => null);
if (buf) bufferList.push(buf);
}
}
if (bufferList.length === 0) return null;
return Buffer.concat(bufferList);
}
private textEditor(guild: Guild, text: string): string {
if (text.length === 0) return "파일";
return text
.replace(URL_RE, (u) => labelForUrl(u) ?? u)
.replace(/\<\@\!?[(0-9)]{18}\>/g, (t) => {
const member = guild.members.cache.get(t.replace(/[^0-9]/g,""));
return member?.nickname ?? member?.user.username ?? "유저";
})
.replace(/\<a?\:.*\:[(0-9)]{18}\>/g, () => {
return "이모티콘";
});
}
private textReplace(text: string): string {
return text.replace(
new RegExp(Object.keys(Config.replaceObj).join("|"), "gi"),
(t) => {
return Config.replaceObj[t] ?? Config.replaceObj["\\"+t] ?? t;
}
);
}
}

104
src/classes/VoiceSession.ts Normal file
View File

@@ -0,0 +1,104 @@
import { AudioPlayerStatus, createAudioPlayer, entersState, joinVoiceChannel, NoSubscriberBehavior, VoiceConnection, VoiceConnectionStatus } from "@discordjs/voice";
import { Guild, VoiceBasedChannel } from "discord.js";
import { Logger } from "../utils/Logger";
import { createResourceFromMp3Buffer } from "../utils/Transcode";
export class VoiceSession {
private player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Pause } });
private generation = 0;
private isSessionEnd = false;
private lastTime = Date.now();
private endTimer?: NodeJS.Timeout | null;
private connection: VoiceConnection;
constructor(
private guild: Guild,
private channel: VoiceBasedChannel,
private endTimeMs: number,
private onSessionEnd: (guildId: string) => void
) {
this.connection = joinVoiceChannel({
guildId: this.guild.id,
channelId: this.channel.id,
adapterCreator: this.guild.voiceAdapterCreator,
selfDeaf: true,
selfMute: false,
});
this.init();
}
private async init() {
this.connection.subscribe(this.player);
await entersState(this.connection, VoiceConnectionStatus.Ready, 20_000).catch(() => {});
this.player.on(AudioPlayerStatus.Playing, () => {
this.startEndTimer();
});
this.player.on(AudioPlayerStatus.Idle, () => {
this.startEndTimer();
});
this.player.on("error", (e) => {
Logger.error("player error:"+String(e));
this.startEndTimer();
});
}
private startEndTimer() {
if (this.endTimer) clearTimeout(this.endTimer);
this.endTimer = setTimeout(() => {
this.endTimer = null;
if (
!this.isSessionEnd
&& this.player.state.status === AudioPlayerStatus.Idle
&& (Date.now() - this.lastTime) >= this.endTimeMs
) {
this.sessionEnd();
} else {
this.startEndTimer();
}
}, this.endTimeMs);
}
public async play(mp3Buf: Buffer) {
if (this.isSessionEnd) return;
const myGen = ++this.generation;
this.lastTime = Date.now();
this.startEndTimer();
const res = createResourceFromMp3Buffer(mp3Buf);
if (myGen !== this.generation || this.isSessionEnd) return;
// try { this.player.stop(true); } catch {};
try { this.player.play(res); } catch {};
}
async moveTo(newChannel: VoiceBasedChannel) {
if (this.isSessionEnd) return;
if (this.channel.id === newChannel.id) return;
this.channel = newChannel;
this.startEndTimer();
try { this.connection.disconnect(); } catch {};
try { this.connection.destroy(); } catch {};
this.connection = joinVoiceChannel({
guildId: this.guild.id,
channelId: this.channel.id,
adapterCreator: this.guild.voiceAdapterCreator,
selfDeaf: true,
selfMute: false,
});
this.connection.subscribe(this.player);
await entersState(this.connection, VoiceConnectionStatus.Ready, 20_000).catch(() => {});
}
sessionEnd() {
if (this.isSessionEnd) return;
this.isSessionEnd = true;
if (this.endTimer) clearTimeout(this.endTimer);
this.endTimer = null;
try { this.player.stop(true); } catch {};
try { this.connection.disconnect(); } catch {};
try { this.connection.destroy(); } catch {};
this.onSessionEnd(this.guild.id);
}
}

27
src/commands/example.ts Normal file
View File

@@ -0,0 +1,27 @@
import { client } from "../index";
import { Command } from "../types/Command";
import { Message, ChatInputApplicationCommandData, ChannelType, ChatInputCommandInteraction } from "discord.js";
/** example 명령어 */
export default class implements Command {
/** 해당 명령어 설명 */
name = "example";
visible = false;
aliases: string[] = ["예시"];
description: string = "예시 명령어";
metaData: ChatInputApplicationCommandData = {
name: this.name,
description: this.description,
};
/** 실행되는 부분 */
async slashRun(interaction: ChatInputCommandInteraction) {
await interaction.editReply({ embeds: [ client.mkembed({
title: "예시 명령어",
}) ] });
}
async messageRun(message: Message) {
if (message.channel?.type !== ChannelType.GuildText) return;
return await message.channel.send({ content: "예시 명령어" }).then(m => client.msgDelete(m, 5));
}
}

105
src/commands/help.ts Normal file
View File

@@ -0,0 +1,105 @@
import { client, handler } from "../index";
import { Command } from "../types/Command";
import { CacheType, Message, ActionRowBuilder, EmbedBuilder, ChatInputApplicationCommandData, StringSelectMenuBuilder, StringSelectMenuInteraction, ApplicationCommandOptionType, ChannelType, ChatInputCommandInteraction } from "discord.js";
/** help 명령어 */
export default class implements Command {
/** 해당 명령어 설명 */
name = "help";
visible = true;
aliases: string[] = ["도움말"];
description: string = "명령어 확인";
metaData: ChatInputApplicationCommandData = {
name: this.name,
description: this.description,
};
/** 실행되는 부분 */
async slashRun(interaction: ChatInputCommandInteraction) {
await interaction.editReply(this.getHelp());
}
async messageRun(message: Message) {
if (message.channel?.type !== ChannelType.GuildText) return;
return await message.channel.send(this.getHelp()).then(m => client.msgDelete(m, 5));
}
async menuRun(interaction: StringSelectMenuInteraction<CacheType>, args: string[]) {
const command = handler.commands.get(args[0]);
var embed = client.mkembed({});
var embed2: EmbedBuilder | undefined = undefined;
if (command) {
embed.setTitle(`\` /${args[0]} 도움말 \``)
.setDescription(`이름: ${args[0]}\n설명: ${command.description}`);
embed2 = helpData(command.metaData.name, command.metaData);
} else {
embed.setTitle(`\` ${args[0]} 도움말 \``)
.setDescription(`명령어를 찾을수 없습니다.`)
.setFooter({ text: `도움말: /help` })
.setColor('DarkRed');
}
if (embed2) {
await interaction.editReply({ embeds: [ embed, embed2 ] });
return;
}
await interaction.editReply({ embeds: [ embed ] });
}
getHelp(): { embeds: EmbedBuilder[], components: ActionRowBuilder<StringSelectMenuBuilder>[] } {
const slashcmdembed = client.mkembed({
title: `\` slash (/) 도움말 \``,
description: `명령어\n명령어 설명`
});
const msgcmdembed = client.mkembed({
title: `\` 기본 (${client.prefix}) 도움말 \``,
description: `명령어 [같은 명령어]\n명령어 설명`,
footer: { text: `PREFIX: ${client.prefix}` }
});
let cmdlist: { label: string, description: string, value: string }[] = [];
handler.commands.forEach((cmd) => {
if (cmd.slashRun && cmd.visible) {
cmdlist.push({ label: `/${cmd.name}`, description: `${cmd.description}`, value: `${cmd.name}` });
slashcmdembed.addFields([{ name: `**/${cmd.name}**`, value: `${cmd.description}`, inline: true }]);
}
});
handler.commands.forEach((cmd) => {
if (cmd.messageRun && cmd.visible) {
msgcmdembed.addFields([{ name: `**${client.prefix}${cmd.name}${cmd.aliases ? ` [ ${cmd.aliases} ]` : ""}**`, value: `${cmd.description}`, inline: true }]);
}
});
const rowhelp = client.mkembed({
title: '\` 명령어 상세보기 \`',
description: `명령어의 자세한 내용은\n아래의 선택박스에서 선택해\n확인할수있습니다.`,
footer: { text: '여러번 가능' }
});
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder()
.setCustomId('help')
.setPlaceholder('명령어를 선택해주세요.')
.addOptions(cmdlist)
);
return { embeds: [ slashcmdembed, msgcmdembed, rowhelp ], components: [ row ] };
}
}
function helpData(name: string, metadata: ChatInputApplicationCommandData): EmbedBuilder | undefined {
var text = "";
metadata.options?.forEach((opt) => {
text += `/${name} ${opt.name}`;
if (opt.type === ApplicationCommandOptionType.Subcommand && opt.options) {
if (opt.options.length > 1) {
text = "";
opt.options.forEach((opt2) => {
text += `/${name} ${opt.name} [${opt2.type}] : ${opt.description}\n`;
});
} else {
text += ` [${opt.options[0].type}] : ${opt.description}\n`;
}
} else {
text += ` : ${opt.description}\n`;
}
});
if (!text || text.length == 0) return undefined;
return client.mkembed({
title: `\` ${name} 명령어 \``,
description: text,
});
}

41
src/commands/ping.ts Normal file
View File

@@ -0,0 +1,41 @@
import { client } from "../index";
import { Command } from "../types/Command";
import { Message, ChatInputApplicationCommandData, ChannelType, ChatInputCommandInteraction } from "discord.js";
/** ping 명령어 */
export default class implements Command {
/** 해당 명령어 설명 */
name = "ping";
visible = true;
aliases: string[] = ["핑"];
description: string = "기본 명령어";
metaData: ChatInputApplicationCommandData = {
name: this.name,
description: this.description,
};
/** 실행되는 부분 */
async slashRun(interaction: ChatInputCommandInteraction) {
const msg = await interaction.editReply({ embeds: [ client.mkembed({
title: "핑...",
description: "계산중...",
}) ] });
const ping = msg.createdTimestamp - interaction.createdTimestamp;
await interaction.editReply({ embeds: [ client.mkembed({
title: "퐁!!",
description: `${ping}ms`,
}) ] });
}
async messageRun(message: Message) {
if (message.channel?.type !== ChannelType.GuildText) return;
const msg = await message.channel.send({ embeds: [ client.mkembed({
title: "핑...",
description: "계산중...",
}) ] });
const ping = msg.createdTimestamp - message.createdTimestamp;
return await msg.edit({ embeds: [ client.mkembed({
title: "퐁!!",
description: `${ping}ms`,
}) ] }).then(m => client.msgDelete(m, 1));
}
}

31
src/commands/signature.ts Normal file
View File

@@ -0,0 +1,31 @@
import { client } from "../index";
import { Command } from "../types/Command";
import { Message, ChatInputApplicationCommandData, ChannelType, ChatInputCommandInteraction } from "discord.js";
/** signature 명령어 */
export default class implements Command {
/** 해당 명령어 설명 */
name = "시그니처";
visible = true;
aliases: string[] = ["signature"];
description: string = "시그니처";
metaData: ChatInputApplicationCommandData = {
name: this.name,
description: this.description,
};
/** 실행되는 부분 */
async slashRun(interaction: ChatInputCommandInteraction) {
await interaction.editReply({ embeds: [ client.mkembed({
title: "시그니처 사이트",
url: "https://sig.tkrmagid.kr",
}) ] });
}
async messageRun(message: Message) {
if (message.channel?.type !== ChannelType.GuildText) return;
return await message.channel.send({ embeds: [ client.mkembed({
title: "시그니처 사이트",
url: "https:sig.tkrmagid.kr",
}) ] }).then(m => client.msgDelete(m, 5));
}
}

129
src/commands/tts.ts Normal file
View File

@@ -0,0 +1,129 @@
import { client } from "../index";
import { Command } from "../types/Command";
import { DB } from "../utils/Database";
import { Message, ChatInputApplicationCommandData, ChannelType, ApplicationCommandOptionType, ChatInputCommandInteraction, EmbedBuilder, Guild } from "discord.js";
/** tts 명령어 */
export default class implements Command {
/** 해당 명령어 설명 */
name = "tts";
visible = true;
aliases: string[] = [];
description: string = "tts 명령어";
metaData: ChatInputApplicationCommandData = {
name: this.name,
description: this.description,
options: [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: "channel",
description: "채널 관련",
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: "make",
description: "채널 만들기",
},
{
type: ApplicationCommandOptionType.Subcommand,
name: "register",
description: "기존 채팅채널 등록",
options: [
{
type: ApplicationCommandOptionType.Channel,
name: "channel",
description: "등록할 채널 (선택)",
channel_types: [ChannelType.GuildText],
},
{
type: ApplicationCommandOptionType.String,
name: "channel_id",
description: "채널 ID 또는 #멘션 (선택)",
},
]
}
]
},
]
};
/** 실행되는 부분 */
async slashRun(interaction: ChatInputCommandInteraction) {
const group = interaction.options.getSubcommandGroup();
const cmd = interaction.options.getSubcommand();
if (group === "channel") {
if (cmd === "make") {
await interaction.editReply({ embeds: [ await this.channel_create(interaction.guild) ] });
return;
}
if (cmd === "register") {
const channel = interaction.options.getChannel("channel");
const channelId = interaction.options.getString("channel_id");
await interaction.editReply({ embeds: [ this.channel_register(interaction.guild, channel?.id || channelId) ] });
return;
}
}
}
async messageRun(message: Message) {
if (message.channel?.type !== ChannelType.GuildText) return;
return await message.channel.send({ content: "예시 명령어" }).then(m => client.msgDelete(m, 5));
}
async channel_create(guild: Guild | null): Promise<EmbedBuilder> {
if (!guild) return client.mkembed({
title: "guild를 가져올수 없습니다.",
color: "DarkRed",
});
const channel = await guild.channels.create({
name: "TTS채널",
type: ChannelType.GuildText,
topic: "채팅치면 봇이 음성채널에 와서 읽어줍니다.",
});
channel.send({ embeds: [ client.mkembed({
title: "TTS 채널입니다.",
description: [
"**음성채널에 들어간 후",
"이곳에 채팅을 입력하면",
`봇 ( <@${client.user?.id}> )(이)가 음성에`,
"들어와 채팅을 읽어줍니다.**"
].join("\n"),
}) ] });
return this.channel_register(guild, channel.id);
}
channel_register(guild: Guild | null, channelId: string | null): EmbedBuilder {
if (!guild) return client.mkembed({
title: "guild를 가져올수 없습니다.",
color: "DarkRed",
});
if (!channelId) return client.mkembed({
title: "채널 아이디 가져오기 오류",
color: "DarkRed",
});
const channel = guild.channels.cache.get(channelId.replace(/\<|\#|\!|\>/g,"").trim());
if (!channel?.id) return client.mkembed({
title: `${channelId} 채널 가져오기 오류`,
color: "DarkRed",
});
if (!DB.guild.get(guild.id) && !DB.guild.set({
id: guild.id,
name: guild.name,
channel_id: "",
})) return client.mkembed({
title: "DB 생성 오류",
color: "DarkRed",
});
if (!DB.guild.update({
id: guild.id,
name: guild.name,
channel_id: channel.id,
})) return client.mkembed({
title: "DB 등록 오류",
color: "DarkRed",
});
return client.mkembed({
title: "채널 생성/등록 완료",
description: `채널: <#${channel.id}>`,
});
}
}

21
src/events/clientReady.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Config } from "../utils/Config";
import { client, handler } from "../index";
import { Logger } from "../utils/Logger";
export const clientReady = async () => {
if (!client.user) return;
Logger.ready(`봇 활성화! ${client.user.username}`);
client.user.setActivity({
name: "TTS 봇",
});
if (!Config.dev) return;
try {
const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData));
await client.application?.commands.set(body, Config.guildId);
Logger.ready(`DEV 길드(${Config.guildId}) 슬래시 등록: ${body.length}`);
} catch (err) {
throw err instanceof Error ? err : new Error(String(err));
}
}

11
src/events/index.ts Normal file
View File

@@ -0,0 +1,11 @@
import { clientReady } from "./clientReady";
import { interactionCreate } from "./interactionCreate";
import { messageCreate } from "./messageCreate";
import { voiceStateUpdate } from "./voiceStateUpdate";
export const onEvents = {
clientReady,
interactionCreate,
messageCreate,
voiceStateUpdate,
}

View File

@@ -0,0 +1,29 @@
import { Interaction, MessageFlags } from "discord.js";
import { handler } from "../index";
export const interactionCreate = async (interaction: Interaction) => {
if (interaction.isStringSelectMenu()) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
const commandName = interaction.customId;
const args = interaction.values;
const command = handler.commands.get(commandName);
if (command?.menuRun) return await command.menuRun(interaction, args);
}
if (interaction.isButton()) {
const args = interaction.customId.split("-");
if (!args || args.length === 0) return;
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
const command = handler.commands.get(args.shift()!);
if (command?.buttonRun) return command.buttonRun(interaction, args);
}
if (!interaction.isChatInputCommand()) return;
/**
* 명령어 친사람만 보이게 설정
* ephemeral: true
*/
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
handler.runCommand(interaction);
}

View File

@@ -0,0 +1,52 @@
import { ChannelType, Message } from "discord.js";
import { client, handler, tts } from "../index";
import { Config } from "../utils/Config";
import { Logger } from "../utils/Logger";
import { DB } from "../utils/Database";
const cmdErr = (message: Message, commandName: string | undefined | null): void => {
if (!commandName) return;
if (message.channel.type !== ChannelType.GuildText) return;
message.channel.send({ embeds: [ client.mkembed({
description: `\` ${commandName} \` 이라는 명령어를 찾을 수 없습니다.`,
footer: { text: `${Config.prefix}help를 입력해 명령어를 확인해주세요.` },
color: "DarkRed",
}) ] }).then(m => client.msgDelete(m, 1));
}
export const messageCreate = async (message: Message): Promise<void> => {
if (message.author.bot || message.channel.type === ChannelType.DM) return;
if (!message.content.startsWith(client.prefix)) {
handleTTSMessage(message);
return;
}
const content = message.content.slice(client.prefix.length).trim();
const args = content.split(/ +/g);
const commandName = args.shift()?.toLocaleLowerCase() || "";
const command = handler.commands.get(commandName) || handler.commands.find((cmd) => cmd.aliases.includes(commandName));
try {
if (!command || !command.messageRun) {
if (!commandName || commandName.replace(/\;| +/g,"").length === 0) return;
cmdErr(message, commandName);
return client.msgDelete(message, 0, true);
}
command.messageRun(message, args);
client.msgDelete(message, 0, true);
} catch(err: any) {
if (Config.debug) Logger.error(err);
cmdErr(message, commandName);
return client.msgDelete(message, 0, true);
}
}
async function handleTTSMessage(message: Message): Promise<void> {
if (!message.guild?.id) return;
if (!message.member?.user?.id) return;
if (message.channel.type !== ChannelType.GuildText) return;
const db = DB.guild.get(message.guild.id);
if (!db?.channel_id) return;
if (db.channel_id !== message.channel.id) return;
tts.tts(message.channel, message.member, message.content.trim());
}

View File

@@ -0,0 +1,9 @@
import { VoiceState } from "discord.js";
import { client, tts } from "../index";
export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise<void> => {
// if (oldState) leave(oldState);
// if (newState) join(newState);
if (oldState.member?.id === client.user?.id && oldState.channel && !newState.channel) return tts.endSession(oldState.guild.id);
if (oldState.member?.id === client.user?.id && oldState.channel && newState.channel) return tts.findSession(oldState.guild.id)?.moveTo(newState.channel);
}

14
src/index.ts Normal file
View File

@@ -0,0 +1,14 @@
import { BotClient } from "./classes/BotClient";
import { Handler } from "./classes/Handler";
import { TTSClient } from "./classes/TTSClient";
import { SignatureClient } from "./classes/SignatureClient";
import { onEvents } from "./events";
export const client = new BotClient();
export const handler = new Handler();
export const tts = new TTSClient();
export const signature = new SignatureClient();
for (const eventName of Object.keys(onEvents) as (keyof typeof onEvents)[]) {
client.onEvent(eventName, onEvents[eventName]);
}

22
src/types/Command.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
import { ButtonInteraction, ChatInputApplicationCommandData, ChatInputCommandInteraction, Message, StringSelectMenuInteraction } from "discord.js";
export interface Command {
/** 메세지 이름 */
name: string;
/** help 목록 노출 여부 */
visible: boolean;
/** 메세지 기반 별칭 */
aliases: string[];
/** 간단 설명 */
description: string;
/**
* 등록 메타: JSON 변환된 바디
* (빌드 시 toJSON()해서 REST 등록에 사용)
*/
metaData: RESTPostAPIChatInputApplicationCommandsJSONBody;
slashRun?: (args: ChatInputCommandInteraction) => Promise<void>;
messageRun?: (message: Message, args: string[]) => Promise<void>;
menuRun?: (interaction: StringSelectMenuInteraction, args: string[]) => Promise<void>;
buttonRun?: (interaction: ButtonInteraction, args: string[]) => Promise<void>;
}

56
src/utils/Config.ts Normal file
View File

@@ -0,0 +1,56 @@
import "dotenv/config";
import { join } from "node:path";
import { def_replaceObj } from "./tts/Utils";
export const Config = {
_appId: process.env.APPID?.trim(),
_token: process.env.TOKEN?.trim(),
_prefix: process.env.PREFIX?.trim(),
_dbPath: process.env.DBPATH?.trim(),
_guildId: process.env.GUILDID?.trim(),
_chzzk: {
nid_aut: process.env.CHZZK_NID_AUT?.trim(),
nid_ses: process.env.CHZZK_NID_SES?.trim(),
},
_ttsPath: process.env.TTSPATH?.trim(),
debug: process.env.DEBUG?.trim()?.toLocaleLowerCase() === "true",
dev: process.env.DEV?.trim()?.toLocaleLowerCase() === "true",
replaceObj: { ...def_replaceObj, ...JSON.parse(process.env.REPLACETEXT?.trim() || "[{}]")[0] },
get appId() {
if (!this._appId) throw new ReferenceError("APPID is missing");
return this._appId;
},
get token() {
if (!this._token) throw new ReferenceError("TOKEN is missing");
return this._token;
},
get prefix() {
if (!this._prefix) throw new ReferenceError("PREFIX is missing");
return this._prefix;
},
get dbPath() {
if (!this._dbPath) throw new ReferenceError("DBPATH is missing");
return this._dbPath;
},
get guildId() {
if (!this._guildId) throw new ReferenceError("GUILDID is missing");
return this._guildId;
},
get chzzk() {
if (!this._chzzk.nid_aut) throw new ReferenceError("CHZZK_NID_AUT is missing");
if (!this._chzzk.nid_ses) throw new ReferenceError("CHZZK_NID_SES is missing");
return {
nid_aut: this._chzzk.nid_aut,
nid_ses: this._chzzk.nid_ses,
};
},
get ttsPath() {
if (!this._ttsPath) throw new ReferenceError("TTSPATH is missing");
return this._ttsPath;
}
};
// 명령어 폴더
export const COMMANDS_PATH = join(__dirname, "..", "commands");
export const COMMAND_PATH = (commandFile: string) => join(COMMANDS_PATH, commandFile);

112
src/utils/Database.ts Normal file
View File

@@ -0,0 +1,112 @@
import Database from "better-sqlite3";
import { Config } from "./Config";
import { dirname, join } from "node:path";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { GuildType, UserType } from "../../db/db";
import { Logger } from "./Logger";
const dbDir = dirname(Config.dbPath); // './db' 추출
if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
const database = new Database(Config.dbPath);
const schemaPath = join(process.cwd(), "db/schema.sql");
const schema = readFileSync(schemaPath, "utf-8");
database.exec(schema);
Logger.ready("DB 활성화!");
const stmt = {
guild: {
// 가져오기
get: database.prepare("SELECT * FROM guilds WHERE ID = ?"),
// 추가
insert: (data: GuildType) => {
const keys = Object.keys(data);
if (keys.length === 0) throw new Error("insert: 키1개는 있어야함");
return database.prepare(`INSERT INTO guilds (${
keys.map(k => `"${k}"`).join(", ")
}) VALUES (${
keys.map(k => `@${k}`).join(", ")
})`).run(data);
},
// 수정
update: (data: GuildType) => {
const keys = Object.keys(data).filter(k => k !== "id");
if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
return database.prepare(`UPDATE guilds SET ${
keys.map(k => `${k} = @${k}`).join(", ")
} 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 guilds SET ${
keys.map(k => `${k} = @${k}`).join(", ")
} WHERE guild_id = @guild_id AND id = @id`).run(data);
},
},
};
export const DB = {
guild: {
get(id: string) {
return stmt.guild.get.get(id) as GuildType | undefined;
},
set(data: GuildType) {
try {
stmt.guild.insert(data);
return true;
} catch (err) {
Logger.error(String(err));
return false;
}
},
update(data: GuildType) {
try {
stmt.guild.update(data);
return true;
} catch (err) {
Logger.error(String(err));
return false;
}
},
},
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;
}
},
},
};

38
src/utils/Logger.ts Normal file
View File

@@ -0,0 +1,38 @@
import colors from "colors/safe";
export const Timestamp = () => {
const Now = new Date();
Now.setHours(Now.getHours() + 9);
return Now.toISOString().replace('T', ' ').substring(0, 19).slice(2);
}
type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash";
const log = (content: string, type: logType) => {
const timestamp = colors.white(`[${Timestamp()}]`);
switch (type) {
case "log":
return console.log(`${colors.gray("[LOG]")} ${timestamp} ${content}`);
case "info":
return console.log(`${colors.cyan("[INFO]")} ${timestamp} ${content}`);
case "warn":
return console.log(`${colors.yellow("[WARN]")} ${timestamp} ${content}`);
case "error":
return console.log(`${colors.red("[ERROR]")} ${timestamp} ${content}`);
case "debug":
return console.log(`${colors.magenta("[DEBUG]")} ${timestamp} ${content}`);
case "ready":
return console.log(`${colors.green("[READY]")} ${timestamp} ${content}`);
default:
throw new TypeError("Logger 타입이 올바르지 않습니다.");
}
};
export const Logger = {
log: (content: string) => log(content, "log"),
warn: (content: string) => log(content, "warn"),
error: (content: string) => log(content, "error"),
debug: (content: string) => log(content, "debug"),
info: (content: string) => log(content, "info"),
ready: (content: string) => log(content, "ready")
}

View File

@@ -0,0 +1,16 @@
import { REST, Routes } from "discord.js";
import { Config } from "./Config";
import { handler } from "../index";
import { Logger } from "./Logger";
async function main() {
const rest = new REST({ version: "10" }).setToken(Config.token);
const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData));
// 전역 등록 (권장: 배포 파이프라인에서만 실행)
await rest.put(Routes.applicationCommands(Config.appId), { body });
Logger.ready(`전역 슬래시 등록 요청 완료: ${body.length}`);
}
main().catch((err) => {
throw err instanceof Error ? err : new Error(String(err));
});

35
src/utils/Transcode.ts Normal file
View File

@@ -0,0 +1,35 @@
import ffmpegPath from "ffmpeg-static";
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { Readable } from "node:stream";
import { AudioResource, createAudioResource, StreamType } from "@discordjs/voice";
/** MP3 Buffer -> PCM(s16le 48k 2ch) Readable stream */
function mp3BufferToPcmStream(mp3Buf: Buffer): Readable {
if (!ffmpegPath) throw new Error("ffmpeg-static 경로 확인 실패");
const ff: ChildProcessWithoutNullStreams = spawn(ffmpegPath, [
"-loglevel","quiet","-hide_banner",
"-i","pipe:0", // stdin으로 mp3
"-f","s16le", // raw PCM
"-ar","48000", // 48k
"-ac","2", // stereo
"pipe:1" // stdout으로 PCM
], { stdio: ["pipe","pipe","pipe"] });
// 입력 밀어넣고 닫기
// ff.stdin.write(mp3Buf);
// ff.stdin.end();
Readable.from(mp3Buf).pipe(ff.stdin);
// ffmpeg stdout(PCM)을 그대로 리턴
// (에러 로그가 필요하면 ff.stderr 'data' 핸들링 추가)
return ff.stdout;
}
export function createResourceFromMp3Buffer(mp3Buf: Buffer): AudioResource {
const pcmStream = mp3BufferToPcmStream(mp3Buf);
return createAudioResource(pcmStream, {
inputType: StreamType.Raw,
inlineVolume: false, // 필요하면 true로 하고 volume 조절
});
}

51
src/utils/tts/Chzzk.ts Normal file
View File

@@ -0,0 +1,51 @@
import axios from "axios";
import { Config } from "../Config";
export enum VoiceType {
// 현재(25.08.27)
= "yuna",
= "garam",
= "dain",
= "meow",
= "kyungtae",
= "mammon",
= "seungpyo",
= "woosik",
= "sangdo",
// 이전
= "hajun",
};
const defHeaders = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
"Cookie": Object.entries(Config.chzzk).map(([key, value]) => key+'='+value).join(';'),
};
const getToken = () => new Promise<string>((res, rej) => {
axios.get("https://api.chzzk.naver.com/service/v1/alerts/token", {
headers: defHeaders,
responseType: "json"
}).then((val) => {
if (!val.data?.content?.token) return rej(val.data?.message || "오류");
return res(val.data.content.token);
}).catch((err) => {
return rej(err.response?.data?.message || "오류");
});
});
export const textToSpeech = (text: string, voice: VoiceType) => new Promise<Buffer>(async (res, rej) => {
axios.post("https://api.chzzk.naver.com/service/v1/alerts/tts", {
message: text,
token: await getToken().catch(() => "undefined"),
type: "n"+voice
}, {
headers: defHeaders,
responseType: "arraybuffer"
}).then((val) => {
if (val.status !== 200) return rej(val.data?.message || "오류");
return res(val.data);
}).catch((err) => {
return rej(err.response?.data?.message || "오류");
});
});

21
src/utils/tts/Google.ts Normal file
View File

@@ -0,0 +1,21 @@
import axios from "axios";
const SPEED = 0.5;
export const textToSpeech = (text: string) => new Promise<Buffer>(async (res, rej) => {
axios.get(`https://www.google.com/speech-api/v1/synthesize?${new URLSearchParams({
"text": text,
"lang": "ko-kr",
"speed": SPEED.toString()
}).toString()}`, {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
},
responseType: "arraybuffer"
}).then((val) => {
if (val.status !== 200) return rej("오류");
return res(val.data);
}).catch(() => {
return rej("오류");
});
});

24
src/utils/tts/Utils.ts Normal file
View File

@@ -0,0 +1,24 @@
export const def_replaceObj: Record<string, string> = {
"\\?": "물음표",
"\\!": "느낌표",
"\\~": "물결",
"\\+": "더하기",
"\\-": "빼기",
"\\(": "여는소괄호",
"\\)": "닫는소괄호",
"\\{": "여는중괄호",
"\\}": "닫는중괄호",
"\\[": "여는대괄호",
"\\]": "닫는대괄호",
"ㄹㅇ": "리얼",
"ㄲㅂ": "까비",
"ㅎㅇ": "하이",
"ㅇㅋ": "오키",
"ㄴㅇㅅ": "나이스",
"ㅇㅈ": "인정",
"ㅅㄱ": "수고",
"ㅇㅎ": "아하",
"ㄱㄱ": "기역기역",
"ㅃㄹ": "빨리",
"ㄱㅊ": "괜찮",
};