Compare commits
7 Commits
9288127561
...
08de63b448
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08de63b448 | ||
|
|
acdaa4734f | ||
|
|
35569ddd88 | ||
|
|
fe10ed1bd9 | ||
|
|
9123afc14e | ||
|
|
e22cb53ccf | ||
|
|
7b56d2e055 |
14
README.md
14
README.md
@@ -73,6 +73,7 @@ db/
|
|||||||
| `DEV` | 선택 | `true`면 글로벌 대신 `GUILDID`에만 슬래시 등록 |
|
| `DEV` | 선택 | `true`면 글로벌 대신 `GUILDID`에만 슬래시 등록 |
|
||||||
| `DEBUG` | 선택 | `true`면 명령어 오류 스택 로깅 |
|
| `DEBUG` | 선택 | `true`면 명령어 오류 스택 로깅 |
|
||||||
| `REPLACETEXT` | 선택 | 치환 사전(JSON 배열). 기본 사전(`def_replaceObj`)과 병합 |
|
| `REPLACETEXT` | 선택 | 치환 사전(JSON 배열). 기본 사전(`def_replaceObj`)과 병합 |
|
||||||
|
| `SIGNATURE_HOST` | 선택 | 시그니처 서버 `host:port`. 미설정 시 `192.168.10.5:2967` 기본값 |
|
||||||
| `TTSPATH` | 선택 | `Config.ttsPath` getter만 정의돼 있고 현재 호출처 없음. 향후 사용 대비 환경변수만 받아둠 |
|
| `TTSPATH` | 선택 | `Config.ttsPath` getter만 정의돼 있고 현재 호출처 없음. 향후 사용 대비 환경변수만 받아둠 |
|
||||||
|
|
||||||
## 실행
|
## 실행
|
||||||
@@ -86,22 +87,13 @@ npm run dev
|
|||||||
|
|
||||||
# 빌드 후 실행 (ts-cleaner가 dist/를 스캔하므로 먼저 만들어야 함)
|
# 빌드 후 실행 (ts-cleaner가 dist/를 스캔하므로 먼저 만들어야 함)
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
npm run build # ⚠️ 현재 tsconfig 결함으로 실패 — 아래 "알려진 빌드 결함" 참고
|
npm run build
|
||||||
npm start
|
npm start
|
||||||
|
|
||||||
# 전역 슬래시 명령어 등록 (배포 파이프라인)
|
# 전역 슬래시 명령어 등록 (배포 파이프라인)
|
||||||
npm run prod
|
npm run prod
|
||||||
```
|
```
|
||||||
|
|
||||||
> **알려진 빌드 결함 (TypeScript 6+ 환경)**
|
|
||||||
>
|
|
||||||
> 현재 `tsconfig.json` 상태에서 `tsc` 가 다음 두 에러로 실패한다:
|
|
||||||
> - `TS5101`: `baseUrl` 옵션이 사용중지(deprecated). `"ignoreDeprecations": "6.0"` 추가 필요
|
|
||||||
> - `TS5011`: `outDir`만 지정돼 있고 `rootDir` 미지정. `"rootDir": "./src"` 추가 필요
|
|
||||||
>
|
|
||||||
> Docker 빌드도 내부에서 `npm run build`를 실행하므로 같은 사유로 실패한다.
|
|
||||||
> 임시 우회로 `npm run dev`(ts-node)를 사용하거나, 위 두 옵션을 `tsconfig.json` `compilerOptions`에 추가하면 정상 빌드된다.
|
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -109,7 +101,7 @@ docker build -t tts_bot .
|
|||||||
docker run --env-file .env -v $(pwd)/db:/app/db tts_bot
|
docker run --env-file .env -v $(pwd)/db:/app/db tts_bot
|
||||||
```
|
```
|
||||||
|
|
||||||
Dockerfile은 `node:20-alpine` 기반이며 `ffmpeg`을 설치한다. 단 위 "알려진 빌드 결함"이 해결돼야 이미지 빌드가 통과한다.
|
Dockerfile은 `node:20-alpine` 기반이며 `ffmpeg`을 설치한다.
|
||||||
|
|
||||||
## 동작 흐름 요약
|
## 동작 흐름 요약
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
"name": "tts_bot",
|
"name": "tts_bot",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "discord bot with typescript",
|
"description": "discord bot with typescript",
|
||||||
"homepage": "https://github.com/tkrmagid/bot.ts#readme",
|
"homepage": "https://git.tkrmagid.kr/tkrmagid/tts_bot",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/tkrmagid/bot.ts/issues"
|
"url": "https://git.tkrmagid.kr/tkrmagid/tts_bot/issues"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/tkrmagid/bot.ts.git"
|
"url": "git+https://git.tkrmagid.kr/tkrmagid/tts_bot.git"
|
||||||
},
|
},
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"author": "tkrmagid",
|
"author": "tkrmagid",
|
||||||
|
|||||||
@@ -8,9 +8,17 @@ export class Handler {
|
|||||||
public coolDown: Map<string, number> = new Map();
|
public coolDown: Map<string, number> = new Map();
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
const commandFiles = readdirSync(COMMANDS_PATH);
|
const commandFiles = readdirSync(COMMANDS_PATH).filter((f) => {
|
||||||
|
// ts-node로 dev 실행 시 .ts, 빌드 후엔 .js. 둘 다 허용하고
|
||||||
|
// 선언 파일(.d.ts)/소스맵(.js.map)은 제외한다.
|
||||||
|
if (f.endsWith(".d.ts")) return false;
|
||||||
|
if (f.endsWith(".js.map") || f.endsWith(".ts.map")) return false;
|
||||||
|
return f.endsWith(".ts") || f.endsWith(".js");
|
||||||
|
});
|
||||||
for (const commandFile of commandFiles) {
|
for (const commandFile of commandFiles) {
|
||||||
const command = new (require(COMMAND_PATH(commandFile)).default)() as Command;
|
const mod = require(COMMAND_PATH(commandFile));
|
||||||
|
if (typeof mod.default !== "function") continue; // not a command module
|
||||||
|
const command = new mod.default() as Command;
|
||||||
this.commands.set(command.metaData.name, command);
|
this.commands.set(command.metaData.name, command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import WebSocket from "ws";
|
|||||||
import ffmpeg from "fluent-ffmpeg";
|
import ffmpeg from "fluent-ffmpeg";
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
|
import { Config } from "../utils/Config";
|
||||||
|
|
||||||
interface SignatureItem {
|
interface SignatureItem {
|
||||||
name: string[];
|
name: string[];
|
||||||
@@ -17,10 +18,13 @@ mkdirSync(filePath, { recursive: true });
|
|||||||
|
|
||||||
export class SignatureClient {
|
export class SignatureClient {
|
||||||
private readonly myClientId = "bot-"+Math.random().toString(36).slice(2, 9);
|
private readonly myClientId = "bot-"+Math.random().toString(36).slice(2, 9);
|
||||||
private readonly host = `192.168.10.5:2967`;
|
private readonly host = Config.signatureHost;
|
||||||
private readonly wsUrl = `ws://${this.host}?clientId=${this.myClientId}`;
|
private readonly wsUrl = `ws://${this.host}?clientId=${this.myClientId}`;
|
||||||
private ws = new WebSocket(this.wsUrl);
|
private ws!: WebSocket;
|
||||||
private signatureList: SignatureItem[] = [];
|
private signatureList: SignatureItem[] = [];
|
||||||
|
private reconnectDelayMs = 1000;
|
||||||
|
private readonly reconnectMaxMs = 30_000;
|
||||||
|
private reconnectTimer?: NodeJS.Timeout;
|
||||||
|
|
||||||
get list() {
|
get list() {
|
||||||
return this.signatureList;
|
return this.signatureList;
|
||||||
@@ -36,8 +40,14 @@ export class SignatureClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private connect() {
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
this.ws.on("open", () => {
|
this.ws.on("open", () => {
|
||||||
Logger.ready("[WS] 서버 연결 성공");
|
Logger.ready("[WS] 서버 연결 성공");
|
||||||
|
this.reconnectDelayMs = 1000; // reset backoff on successful connect
|
||||||
const requestMessage = JSON.stringify({ type: "GET_LIST" });
|
const requestMessage = JSON.stringify({ type: "GET_LIST" });
|
||||||
this.ws.send(requestMessage);
|
this.ws.send(requestMessage);
|
||||||
});
|
});
|
||||||
@@ -54,12 +64,27 @@ export class SignatureClient {
|
|||||||
});
|
});
|
||||||
this.ws.on("close", () => {
|
this.ws.on("close", () => {
|
||||||
Logger.info("[WS] 서버 연결 종료");
|
Logger.info("[WS] 서버 연결 종료");
|
||||||
|
this.scheduleReconnect();
|
||||||
});
|
});
|
||||||
this.ws.on("error", (error) => {
|
this.ws.on("error", (error) => {
|
||||||
Logger.error(`[WS] 오류 발생: ${String(error)}`);
|
Logger.error(`[WS] 오류 발생: ${String(error)}`);
|
||||||
|
// 'error' is typically followed by 'close' on the ws lib, but call
|
||||||
|
// out reconnect anyway in case the socket never closes cleanly.
|
||||||
|
try { this.ws.terminate(); } catch {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect() {
|
||||||
|
if (this.reconnectTimer) return; // already scheduled
|
||||||
|
const delay = this.reconnectDelayMs;
|
||||||
|
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.reconnectMaxMs);
|
||||||
|
Logger.info(`[WS] ${delay}ms 후 재연결 시도`);
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = undefined;
|
||||||
|
this.connect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
async changeVolume(buffer: Buffer, volume: number) {
|
async changeVolume(buffer: Buffer, volume: number) {
|
||||||
try {
|
try {
|
||||||
const inputStream = Readable.from(buffer);
|
const inputStream = Readable.from(buffer);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export class TTSClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
text = this.textEditor(channel.guild, text);
|
text = this.textEditor(channel.guild, text);
|
||||||
const buf = await this.getSorce(text);
|
const buf = await this.getSource(text);
|
||||||
if (!buf) return;
|
if (!buf) return;
|
||||||
|
|
||||||
const session = this.getSession(channel.guild, voiceChannel);
|
const session = this.getSession(channel.guild, voiceChannel);
|
||||||
@@ -91,7 +91,7 @@ export class TTSClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async getSorce(text: string): Promise<Buffer | null> {
|
private async getSource(text: string): Promise<Buffer | null> {
|
||||||
const parts = text.split(signature.regex);
|
const parts = text.split(signature.regex);
|
||||||
const bufferList: Buffer[] = [];
|
const bufferList: Buffer[] = [];
|
||||||
|
|
||||||
@@ -119,11 +119,11 @@ export class TTSClient {
|
|||||||
if (text.length === 0) return "파일";
|
if (text.length === 0) return "파일";
|
||||||
return text
|
return text
|
||||||
.replace(URL_RE, (u) => labelForUrl(u) ?? u)
|
.replace(URL_RE, (u) => labelForUrl(u) ?? u)
|
||||||
.replace(/\<\@\!?[(0-9)]{18}\>/g, (t) => {
|
.replace(/<@!?(\d{17,20})>/g, (_t, id: string) => {
|
||||||
const member = guild.members.cache.get(t.replace(/[^0-9]/g,""));
|
const member = guild.members.cache.get(id);
|
||||||
return member?.nickname ?? member?.user.username ?? "유저";
|
return member?.nickname ?? member?.user.username ?? "유저";
|
||||||
})
|
})
|
||||||
.replace(/\<a?\:.*\:[(0-9)]{18}\>/g, () => {
|
.replace(/<a?:[^:]+:\d{17,20}>/g, () => {
|
||||||
return "이모티콘";
|
return "이모티콘";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const Config = {
|
|||||||
nid_ses: process.env.CHZZK_NID_SES?.trim(),
|
nid_ses: process.env.CHZZK_NID_SES?.trim(),
|
||||||
},
|
},
|
||||||
_ttsPath: process.env.TTSPATH?.trim(),
|
_ttsPath: process.env.TTSPATH?.trim(),
|
||||||
|
_signatureHost: process.env.SIGNATURE_HOST?.trim() || "192.168.10.5:2967",
|
||||||
debug: process.env.DEBUG?.trim()?.toLocaleLowerCase() === "true",
|
debug: process.env.DEBUG?.trim()?.toLocaleLowerCase() === "true",
|
||||||
dev: process.env.DEV?.trim()?.toLocaleLowerCase() === "true",
|
dev: process.env.DEV?.trim()?.toLocaleLowerCase() === "true",
|
||||||
replaceObj: { ...def_replaceObj, ...JSON.parse(process.env.REPLACETEXT?.trim() || "[{}]")[0] },
|
replaceObj: { ...def_replaceObj, ...JSON.parse(process.env.REPLACETEXT?.trim() || "[{}]")[0] },
|
||||||
@@ -48,6 +49,9 @@ export const Config = {
|
|||||||
get ttsPath() {
|
get ttsPath() {
|
||||||
if (!this._ttsPath) throw new ReferenceError("TTSPATH is missing");
|
if (!this._ttsPath) throw new ReferenceError("TTSPATH is missing");
|
||||||
return this._ttsPath;
|
return this._ttsPath;
|
||||||
|
},
|
||||||
|
get signatureHost() {
|
||||||
|
return this._signatureHost;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const stmt = {
|
|||||||
update: (data: UserType) => {
|
update: (data: UserType) => {
|
||||||
const keys = Object.keys(data).filter(k => k !== "guild_id" && k !== "id");
|
const keys = Object.keys(data).filter(k => k !== "guild_id" && k !== "id");
|
||||||
if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
|
if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
|
||||||
return database.prepare(`UPDATE guilds SET ${
|
return database.prepare(`UPDATE users SET ${
|
||||||
keys.map(k => `${k} = @${k}`).join(", ")
|
keys.map(k => `${k} = @${k}`).join(", ")
|
||||||
} WHERE guild_id = @guild_id AND id = @id`).run(data);
|
} WHERE guild_id = @guild_id AND id = @id`).run(data);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
import colors from "colors/safe";
|
import colors from "colors/safe";
|
||||||
|
|
||||||
|
// 컨테이너/호스트의 TZ 설정과 무관하게 항상 KST(Asia/Seoul) 기준으로
|
||||||
|
// 'YY-MM-DD HH:MM:SS' 형식의 타임스탬프를 만든다. 기존 구현은
|
||||||
|
// setHours(+9)로 수동 보정해 서버 TZ가 UTC가 아닐 때 시간이 어긋났다.
|
||||||
|
const KST_FORMATTER = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: "Asia/Seoul",
|
||||||
|
year: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
export const Timestamp = () => {
|
export const Timestamp = () => {
|
||||||
const Now = new Date();
|
const parts = KST_FORMATTER.formatToParts(new Date());
|
||||||
Now.setHours(Now.getHours() + 9);
|
const get = (type: string) => parts.find(p => p.type === type)?.value ?? "00";
|
||||||
return Now.toISOString().replace('T', ' ').substring(0, 19).slice(2);
|
return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}:${get("second")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash";
|
type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash";
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import ffmpegPath from "ffmpeg-static";
|
|||||||
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { AudioResource, createAudioResource, StreamType } from "@discordjs/voice";
|
import { AudioResource, createAudioResource, StreamType } from "@discordjs/voice";
|
||||||
|
import { Logger } from "./Logger";
|
||||||
|
|
||||||
/** MP3 Buffer -> PCM(s16le 48k 2ch) Readable stream */
|
/** MP3 Buffer -> PCM(s16le 48k 2ch) Readable stream */
|
||||||
function mp3BufferToPcmStream(mp3Buf: Buffer): Readable {
|
function mp3BufferToPcmStream(mp3Buf: Buffer): Readable {
|
||||||
if (!ffmpegPath) throw new Error("ffmpeg-static 경로 확인 실패");
|
if (!ffmpegPath) throw new Error("ffmpeg-static 경로 확인 실패");
|
||||||
const ff: ChildProcessWithoutNullStreams = spawn(ffmpegPath, [
|
const ff: ChildProcessWithoutNullStreams = spawn(ffmpegPath, [
|
||||||
"-loglevel","quiet","-hide_banner",
|
"-loglevel","error","-hide_banner",
|
||||||
"-i","pipe:0", // stdin으로 mp3
|
"-i","pipe:0", // stdin으로 mp3
|
||||||
"-f","s16le", // raw PCM
|
"-f","s16le", // raw PCM
|
||||||
"-ar","48000", // 48k
|
"-ar","48000", // 48k
|
||||||
@@ -15,14 +16,20 @@ function mp3BufferToPcmStream(mp3Buf: Buffer): Readable {
|
|||||||
"pipe:1" // stdout으로 PCM
|
"pipe:1" // stdout으로 PCM
|
||||||
], { stdio: ["pipe","pipe","pipe"] });
|
], { stdio: ["pipe","pipe","pipe"] });
|
||||||
|
|
||||||
// 입력 밀어넣고 닫기
|
// ffmpeg가 비정상 종료해도 부모가 죽지 않도록 모든 핸들에 에러 핸들러를 단다.
|
||||||
// ff.stdin.write(mp3Buf);
|
// stdin EPIPE, stdout 소비 실패 등을 조용히 흘려 보내고 자식 프로세스를
|
||||||
// ff.stdin.end();
|
// 강제 종료해 leak을 막는다.
|
||||||
|
const killFf = () => { try { ff.kill("SIGKILL"); } catch {} };
|
||||||
|
ff.on("error", (e) => { Logger.error("ffmpeg spawn error: "+String(e)); killFf(); });
|
||||||
|
ff.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
const msg = chunk.toString().trim();
|
||||||
|
if (msg) Logger.warn("[ffmpeg] "+msg);
|
||||||
|
});
|
||||||
|
ff.stdin.on("error", () => { /* EPIPE on early close */ });
|
||||||
|
ff.stdout.on("error", () => { /* downstream gone */ });
|
||||||
|
|
||||||
Readable.from(mp3Buf).pipe(ff.stdin);
|
Readable.from(mp3Buf).pipe(ff.stdin).on("error", () => {});
|
||||||
|
|
||||||
// ffmpeg stdout(PCM)을 그대로 리턴
|
|
||||||
// (에러 로그가 필요하면 ff.stderr 'data' 핸들링 추가)
|
|
||||||
return ff.stdout;
|
return ff.stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"baseUrl": "./src",
|
"baseUrl": "./src",
|
||||||
|
"rootDir": "./src",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user