diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8da1acd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +__pycache__ +*.pyc +.git +venv/ +node_modules/ +dist/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2836ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +package-lock.json +*.env +dist/ +*.db +src/test.ts +test/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..768468c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20-alpine + +RUN apk add --no-cache ffmpeg + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install + +COPY . . + +RUN mkdir -p dist + +RUN npm run build + +CMD ["npm", "run", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9d958c --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# tts_bot + +## 설명 + * text to speech 봇 + +## 코멘트 + * 현재 제작중... diff --git a/db/db.d.ts b/db/db.d.ts new file mode 100644 index 0000000..9690760 --- /dev/null +++ b/db/db.d.ts @@ -0,0 +1,11 @@ +export interface GuildType { + id: string; + name: string; + channel_id: string; +} + +export interface UserType { + guild_id: string; + id: string; + name: string; +} \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..2d50dba --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,22 @@ +-- 외래키 제약 조건 활성화 +-- 기본적으로 SQLite는 외래키 검사 안함 그래서 켜줘야 함 +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS guilds ( + id TEXT PRIMARY KEY, -- 길드 ID (전역 유일값) + name TEXT NOT NULL, -- 길드 이름 (캐싱용) + channel_id TEXT NOT NULL -- 채팅 ID +); + +CREATE TABLE IF NOT EXISTS users ( + guild_id TEXT NOT NULL, -- 소속 길드 ID, guilds.id를 참조 + id TEXT NOT NULL, -- 유저 ID + name TEXT NOT NULL, -- 유저 이름 (캐싱용) + + -- 복합 기본키: 같은 길드 안에서 id는 중복 불가 + PRIMARY KEY (guild_id, id), + + -- 외래키 설정: guilds.id를 참조 + -- 길드가 삭제되면 소속된 유저도 자동으로 삭제됨 (ON DELETE CASCADE) + FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..62403de --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "tts_bot", + "version": "0.0.1", + "description": "discord bot with typescript", + "homepage": "https://github.com/tkrmagid/bot.ts#readme", + "bugs": { + "url": "https://github.com/tkrmagid/bot.ts/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tkrmagid/bot.ts.git" + }, + "license": "ISC", + "author": "tkrmagid", + "type": "commonjs", + "main": "dist/index.js", + "scripts": { + "build": "ts-cleaner && tsc", + "start": "node .", + "dev": "ts-node src/index.ts", + "prod": "ts-node src/utils/Prod-commands.ts", + "test": "ts-node src/test.ts" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/fluent-ffmpeg": "^2.1.28", + "@types/node": "^24.9.1", + "@types/ws": "^8.18.1", + "ffmpeg-static": "^5.2.0", + "ts-cleaner": "^1.0.5", + "ts-node": "^10.9.2" + }, + "dependencies": { + "@discordjs/opus": "^0.10.0", + "@discordjs/voice": "^0.19.0", + "@snazzah/davey": "^0.1.7", + "axios": "^1.13.0", + "better-sqlite3": "^12.4.1", + "colors": "^1.4.0", + "discord.js": "^14.24.0", + "dotenv": "^17.2.3", + "fluent-ffmpeg": "^2.1.3", + "ws": "^8.18.3" + } +} diff --git a/src/classes/BotClient.ts b/src/classes/BotClient.ts new file mode 100644 index 0000000..d501ea6 --- /dev/null +++ b/src/classes/BotClient.ts @@ -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))); + } +} \ No newline at end of file diff --git a/src/classes/Handler.ts b/src/classes/Handler.ts new file mode 100644 index 0000000..d0d0451 --- /dev/null +++ b/src/classes/Handler.ts @@ -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 = new Collection(); + public coolDown: Map = 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); + } +} \ No newline at end of file diff --git a/src/classes/SignatureClient.ts b/src/classes/SignatureClient.ts new file mode 100644 index 0000000..8293c88 --- /dev/null +++ b/src/classes/SignatureClient.ts @@ -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((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 { + 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 { + 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; + } + } +} \ No newline at end of file diff --git a/src/classes/TTSClient.ts b/src/classes/TTSClient.ts new file mode 100644 index 0000000..465d999 --- /dev/null +++ b/src/classes/TTSClient.ts @@ -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 = 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 { + 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 { + 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(/\/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; + } + ); + } +} \ No newline at end of file diff --git a/src/classes/VoiceSession.ts b/src/classes/VoiceSession.ts new file mode 100644 index 0000000..8867004 --- /dev/null +++ b/src/classes/VoiceSession.ts @@ -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); + } +} \ No newline at end of file diff --git a/src/commands/example.ts b/src/commands/example.ts new file mode 100644 index 0000000..78fca87 --- /dev/null +++ b/src/commands/example.ts @@ -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)); + } +} \ No newline at end of file diff --git a/src/commands/help.ts b/src/commands/help.ts new file mode 100644 index 0000000..5d64105 --- /dev/null +++ b/src/commands/help.ts @@ -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, 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[] } { + 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().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, + }); + } \ No newline at end of file diff --git a/src/commands/ping.ts b/src/commands/ping.ts new file mode 100644 index 0000000..0002ec3 --- /dev/null +++ b/src/commands/ping.ts @@ -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)); + } +} \ No newline at end of file diff --git a/src/commands/signature.ts b/src/commands/signature.ts new file mode 100644 index 0000000..44ff7da --- /dev/null +++ b/src/commands/signature.ts @@ -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)); + } +} \ No newline at end of file diff --git a/src/commands/tts.ts b/src/commands/tts.ts new file mode 100644 index 0000000..37e6d9e --- /dev/null +++ b/src/commands/tts.ts @@ -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 { + 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}>`, + }); + } +} \ No newline at end of file diff --git a/src/events/clientReady.ts b/src/events/clientReady.ts new file mode 100644 index 0000000..d59b8ca --- /dev/null +++ b/src/events/clientReady.ts @@ -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)); + } +} \ No newline at end of file diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 0000000..665e87f --- /dev/null +++ b/src/events/index.ts @@ -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, +} \ No newline at end of file diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts new file mode 100644 index 0000000..0df3688 --- /dev/null +++ b/src/events/interactionCreate.ts @@ -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); +} \ No newline at end of file diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts new file mode 100644 index 0000000..1ca11c4 --- /dev/null +++ b/src/events/messageCreate.ts @@ -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 => { + 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 { + 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()); +} diff --git a/src/events/voiceStateUpdate.ts b/src/events/voiceStateUpdate.ts new file mode 100644 index 0000000..71931a4 --- /dev/null +++ b/src/events/voiceStateUpdate.ts @@ -0,0 +1,9 @@ +import { VoiceState } from "discord.js"; +import { client, tts } from "../index"; + +export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise => { + // 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); +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c196a2d --- /dev/null +++ b/src/index.ts @@ -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]); +} \ No newline at end of file diff --git a/src/types/Command.d.ts b/src/types/Command.d.ts new file mode 100644 index 0000000..7dd25b5 --- /dev/null +++ b/src/types/Command.d.ts @@ -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; + messageRun?: (message: Message, args: string[]) => Promise; + menuRun?: (interaction: StringSelectMenuInteraction, args: string[]) => Promise; + buttonRun?: (interaction: ButtonInteraction, args: string[]) => Promise; +} \ No newline at end of file diff --git a/src/utils/Config.ts b/src/utils/Config.ts new file mode 100644 index 0000000..2ef8424 --- /dev/null +++ b/src/utils/Config.ts @@ -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); diff --git a/src/utils/Database.ts b/src/utils/Database.ts new file mode 100644 index 0000000..67ca44a --- /dev/null +++ b/src/utils/Database.ts @@ -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; + } + }, + }, +}; \ No newline at end of file diff --git a/src/utils/Logger.ts b/src/utils/Logger.ts new file mode 100644 index 0000000..9d4ace1 --- /dev/null +++ b/src/utils/Logger.ts @@ -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") +} \ No newline at end of file diff --git a/src/utils/Prod-commands.ts b/src/utils/Prod-commands.ts new file mode 100644 index 0000000..79e1263 --- /dev/null +++ b/src/utils/Prod-commands.ts @@ -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)); +}); \ No newline at end of file diff --git a/src/utils/Transcode.ts b/src/utils/Transcode.ts new file mode 100644 index 0000000..4080df1 --- /dev/null +++ b/src/utils/Transcode.ts @@ -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 조절 + }); +} \ No newline at end of file diff --git a/src/utils/tts/Chzzk.ts b/src/utils/tts/Chzzk.ts new file mode 100644 index 0000000..760404c --- /dev/null +++ b/src/utils/tts/Chzzk.ts @@ -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((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(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 || "오류"); + }); +}); \ No newline at end of file diff --git a/src/utils/tts/Google.ts b/src/utils/tts/Google.ts new file mode 100644 index 0000000..5987d1e --- /dev/null +++ b/src/utils/tts/Google.ts @@ -0,0 +1,21 @@ +import axios from "axios"; + +const SPEED = 0.5; + +export const textToSpeech = (text: string) => new Promise(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("오류"); + }); +}); \ No newline at end of file diff --git a/src/utils/tts/Utils.ts b/src/utils/tts/Utils.ts new file mode 100644 index 0000000..8455d53 --- /dev/null +++ b/src/utils/tts/Utils.ts @@ -0,0 +1,24 @@ +export const def_replaceObj: Record = { + "\\?": "물음표", + "\\!": "느낌표", + "\\~": "물결", + "\\+": "더하기", + "\\-": "빼기", + "\\(": "여는소괄호", + "\\)": "닫는소괄호", + "\\{": "여는중괄호", + "\\}": "닫는중괄호", + "\\[": "여는대괄호", + "\\]": "닫는대괄호", + "ㄹㅇ": "리얼", + "ㄲㅂ": "까비", + "ㅎㅇ": "하이", + "ㅇㅋ": "오키", + "ㄴㅇㅅ": "나이스", + "ㅇㅈ": "인정", + "ㅅㄱ": "수고", + "ㅇㅎ": "아하", + "ㄱㄱ": "기역기역", + "ㅃㄹ": "빨리", + "ㄱㅊ": "괜찮", +}; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9eea761 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "commonjs", + "baseUrl": "./src", + "outDir": "./dist", + "removeComments": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "alwaysStrict": true, + "skipLibCheck": true, + + "noImplicitAny": true, // any타입 금지 여부 + "strictNullChecks": true, // null, undefined 타입에 이상한 짓 할시 에러내기 + "strictFunctionTypes": true, // 함수파라미터 타입체크 강하게 + "strictPropertyInitialization": true, // class constructor 작성시 타입체크 강하게 + "noImplicitThis": true, // this 키워드가 any 타입일 경우 에러내기 + + "noUnusedLocals": true, // 쓰지않는 지역변수 있으면 에러내기 + "noUnusedParameters": true, // 쓰지않는 파라미터 있으면 에러내기 + "noImplicitReturns": true, // 함수에서 return 빼먹으면 에러내기 + "noFallthroughCasesInSwitch": true // switch문 이상하면 에러내기 + }, + "include": [ "src" ] +}