Merge upstream tts_bot main
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
venv/
|
||||
node_modules/
|
||||
dist/
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
*.env
|
||||
dist/
|
||||
*.db
|
||||
src/test.ts
|
||||
test/
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -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"]
|
||||
7
README.md
Normal file
7
README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# tts_bot
|
||||
|
||||
## 설명
|
||||
* text to speech 봇
|
||||
|
||||
## 코멘트
|
||||
* 현재 제작중...
|
||||
11
db/db.d.ts
vendored
Normal file
11
db/db.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface GuildType {
|
||||
id: string;
|
||||
name: string;
|
||||
channel_id: string;
|
||||
}
|
||||
|
||||
export interface UserType {
|
||||
guild_id: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
22
db/schema.sql
Normal file
22
db/schema.sql
Normal file
@@ -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
|
||||
);
|
||||
45
package.json
Normal file
45
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
73
src/classes/BotClient.ts
Normal file
73
src/classes/BotClient.ts
Normal 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
25
src/classes/Handler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
172
src/classes/SignatureClient.ts
Normal file
172
src/classes/SignatureClient.ts
Normal 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
139
src/classes/TTSClient.ts
Normal 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
104
src/classes/VoiceSession.ts
Normal 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
27
src/commands/example.ts
Normal 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
105
src/commands/help.ts
Normal 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
41
src/commands/ping.ts
Normal 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
31
src/commands/signature.ts
Normal 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
129
src/commands/tts.ts
Normal 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
21
src/events/clientReady.ts
Normal 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
11
src/events/index.ts
Normal 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,
|
||||
}
|
||||
29
src/events/interactionCreate.ts
Normal file
29
src/events/interactionCreate.ts
Normal 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);
|
||||
}
|
||||
52
src/events/messageCreate.ts
Normal file
52
src/events/messageCreate.ts
Normal 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());
|
||||
}
|
||||
9
src/events/voiceStateUpdate.ts
Normal file
9
src/events/voiceStateUpdate.ts
Normal 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
14
src/index.ts
Normal 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
22
src/types/Command.d.ts
vendored
Normal 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
56
src/utils/Config.ts
Normal 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
112
src/utils/Database.ts
Normal 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
38
src/utils/Logger.ts
Normal 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")
|
||||
}
|
||||
16
src/utils/Prod-commands.ts
Normal file
16
src/utils/Prod-commands.ts
Normal 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
35
src/utils/Transcode.ts
Normal 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
51
src/utils/tts/Chzzk.ts
Normal 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
21
src/utils/tts/Google.ts
Normal 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
24
src/utils/tts/Utils.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const def_replaceObj: Record<string, string> = {
|
||||
"\\?": "물음표",
|
||||
"\\!": "느낌표",
|
||||
"\\~": "물결",
|
||||
"\\+": "더하기",
|
||||
"\\-": "빼기",
|
||||
"\\(": "여는소괄호",
|
||||
"\\)": "닫는소괄호",
|
||||
"\\{": "여는중괄호",
|
||||
"\\}": "닫는중괄호",
|
||||
"\\[": "여는대괄호",
|
||||
"\\]": "닫는대괄호",
|
||||
"ㄹㅇ": "리얼",
|
||||
"ㄲㅂ": "까비",
|
||||
"ㅎㅇ": "하이",
|
||||
"ㅇㅋ": "오키",
|
||||
"ㄴㅇㅅ": "나이스",
|
||||
"ㅇㅈ": "인정",
|
||||
"ㅅㄱ": "수고",
|
||||
"ㅇㅎ": "아하",
|
||||
"ㄱㄱ": "기역기역",
|
||||
"ㅃㄹ": "빨리",
|
||||
"ㄱㅊ": "괜찮",
|
||||
};
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -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" ]
|
||||
}
|
||||
Reference in New Issue
Block a user