Compare commits

...

7 Commits

Author SHA1 Message Date
Claude Owner
08de63b448 docs: refresh README for fixes landed in this branch
- Drop the 'known build defect' callout — tsconfig now compiles
  cleanly with rootDir + ignoreDeprecations applied.
- Add SIGNATURE_HOST to the env var table.
2026-05-26 14:42:35 +09:00
Claude Owner
acdaa4734f chore: harden logger TZ, ffmpeg lifecycle and fix package metadata
- Logger.Timestamp now formats via Intl with timeZone Asia/Seoul, so
  the timestamp is correct regardless of the container/host TZ. The
  previous setHours(+9) hack assumed the system clock was UTC.
- Transcode.mp3BufferToPcmStream now attaches error/stderr handlers
  to the ffmpeg child process and its streams, swallows EPIPE on
  early downstream close, and force-kills on spawn error so failed
  conversions can't leak processes. Log level bumped from 'quiet'
  to 'error' so real ffmpeg errors surface.
- package.json homepage/bugs/repository pointed at github.com/tkrmagid/bot.ts
  which doesn't reflect this repo. Repoint to the actual Gitea origin.
2026-05-26 14:41:29 +09:00
Claude Owner
35569ddd88 fix(handler): filter command files and guard non-command modules
Handler.ts iterated readdirSync(COMMANDS_PATH) without filtering, so
any .d.ts declaration file, .js.map source map, or non-class module
that landed in commands/ would crash startup with 'mod.default is not
a constructor'. Restrict loading to .ts/.js (excluding .d.ts and maps)
and skip modules without a default-exported constructor.
2026-05-26 14:40:29 +09:00
Claude Owner
fe10ed1bd9 feat(signature): make host configurable and add WS reconnect
- New optional env SIGNATURE_HOST overrides the hardcoded
  192.168.10.5:2967 (defaults preserved for back-compat).
- WebSocket now reconnects with exponential backoff (1s, 2s, 4s ...
  capped at 30s) on close/error. Previously a dropped signature
  server connection silently disabled signature playback until the
  bot was restarted.
2026-05-26 14:40:05 +09:00
Claude Owner
9123afc14e chore(ts): make tsconfig compatible with TypeScript 6
- Add rootDir: ./src so tsc no longer errors with TS5011 demanding it
  be set explicitly alongside outDir.
- Add ignoreDeprecations: 6.0 to silence the TS5101 baseUrl deprecation
  warning until baseUrl is removed in TS 7.
2026-05-26 14:39:07 +09:00
Claude Owner
e22cb53ccf fix(tts): correct Discord snowflake regex and getSource typo
- Mention/emoji regexes used [(0-9)]{18}; '(' and ')' inside a character
  class are literal so this matched the bracket chars themselves and
  hard-fixed the ID length to 18. Discord snowflakes are 17-20 digits
  (older 2015-2017 accounts are 17, 2024+ trend toward 19-20). Switch
  to \\d{17,20} and capture the ID for member lookup.
- Tighten emoji regex name segment from .* (greedy, eats across emojis)
  to [^:]+ so adjacent emojis no longer collapse into one match.
- Rename getSorce -> getSource.
2026-05-26 14:38:46 +09:00
Claude Owner
7b56d2e055 fix(db): correct user.update target table
stmt.user.update was issuing UPDATE guilds instead of UPDATE users,
so any DB.user.update() call would silently corrupt guild rows that
happened to share the same WHERE clause shape and never touch the
intended user row.
2026-05-26 14:38:09 +09:00
10 changed files with 86 additions and 34 deletions

View File

@@ -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`을 설치한다.
## 동작 흐름 요약 ## 동작 흐름 요약

View File

@@ -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",

View File

@@ -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);
} }
} }

View File

@@ -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);

View File

@@ -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 "이모티콘";
}); });
} }

View File

@@ -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;
} }
}; };

View File

@@ -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);
}, },

View File

@@ -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";

View File

@@ -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;
} }

View File

@@ -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,