Compare commits

...

15 Commits

Author SHA1 Message Date
Claude Owner
c87a16a683 chore(deps): override transitive tar to ^7.5.15 to clear 5 high CVEs
@discordjs/voice → prism-media → @discordjs/opus → @discordjs/node-pre-gyp
pins tar ^6.1.11. All tar <=7.5.10 are vulnerable (GHSA-34x7-hfp2-rc4v,
8qq5-rm4j-mr97, 83g3-92jg-28cx, qffp-2rhf-9h96, 9ppj-qmqm-q256,
r6q2-hw4h-h46w) with no fix available on the v6 line.

Use npm `overrides` to force tar ^7.5.15 across the dep tree. Verified:
- npm install: `found 0 vulnerabilities`
- docker build --no-cache: succeeds; @discordjs/opus prebuilt is still
  extracted correctly by node-pre-gyp with tar v7
- npm run build: clean
2026-05-27 21:09:57 +09:00
Claude Owner
9eed231244 fix(docker): add python3/make/g++ for native module rebuilds; clarify DEV docs
- @discordjs/opus@0.10.0 has no node>=22 prebuilt, so node-gyp falls back
  to source compile and needs python3/make/g++. Add them to apt-get install
  in the bookworm-slim base. Verified `docker build` succeeds end-to-end.
- .env.example: rewrite DEV description to cover both behaviors
  (clientReady guild-only registration + Prod-commands guild-wipe-then-global).
2026-05-27 21:00:07 +09:00
Claude Owner
94a39e0d45 chore(docker): switch to node:22-bookworm-slim and add .env.example
- Dockerfile base now node:22-bookworm-slim (glibc) so better-sqlite3
  uses prebuilt binaries and node-gyp/python build deps are no longer
  required. Also satisfies @discordjs/voice node>=22.12 engine req.
- Drop redundant `mkdir -p dist` (build script handles it).
- Add .env.example covering TOKEN, APPID, PREFIX, DBPATH, GUILDID,
  CHZZK_NID_AUT/SES, SIGNATURE_HOST, TTSPATH, DEV, DEBUG, REPLACETEXT.
- README: update base image note.
2026-05-27 20:54:00 +09:00
Claude Owner
1d383d9a03 feat(prod-commands): wipe dev guild commands before global registration
When `Config.dev` is true, the operator typically registers slash
commands per-guild (Config.guildId) during iteration so they appear
instantly. Once those leak past dev, the same commands show up in
Discord twice — once from the dev guild registration and once from
the global registration.

Before doing the global PUT, also PUT an empty array against the
dev guild so any stale per-guild commands are cleared. Behavior in
production (Config.dev=false) is unchanged.
2026-05-27 10:20:41 +09:00
Claude Owner
1841828f7a feat(voice): per-user TTS voice selection via /목소리
Until now every TTS message was synthesized with VoiceType.가람
regardless of who spoke. This adds a user-scoped voice preference
persisted in SQLite so each member can pick their own voice and
keep it across bot restarts.

Changes
- db/schema.sql: add nullable `voice_type` column to `users`.
- src/utils/Database.ts:
  - run a PRAGMA-driven ALTER TABLE migration so existing DBs gain
    the column without dropping data.
  - add `DB.user.setVoice(guildId, userId, name, voiceType)` that
    upserts the row.
- src/classes/TTSClient.ts:
  - export `DEFAULT_VOICE` (= 가람).
  - resolve the speaking member's stored voice in `tts()` and
    thread it through `getSource()` instead of hardcoding 가람.
  - validate stored slug against `VoiceType` so stale/unknown
    values silently fall back to the default.
- src/commands/voice.ts (new):
  - `/목소리` slash command shows the user's current voice and a
    StringSelectMenu of all `VoiceType` entries (현재 + 이전 보이스
    모두). Selection writes to `users.voice_type` and confirms
    ephemerally.
  - defensively creates a guild row if `/tts channel register`
    hasn't run yet, to satisfy the ON DELETE CASCADE FK.

Deploy
Run `npm run prod` after pulling so Discord sees the new
`/목소리` command. No env or config changes required.
2026-05-26 22:50:53 +09:00
Claude Owner
2002b1cc12 docs(voice): document interrupt-latest as a design choice
VoiceSession.play() replaces the currently playing TTS on every new
message instead of queueing it. Without this comment a future reader
(or code review) is likely to file it as a missing-feature bug, but
the absence of a queue is intentional: in a many-user voice channel,
queueing causes a speaker's own message to lag far behind the chat
context. Document the policy at the call site.
2026-05-26 14:59:43 +09:00
Claude Owner
204b813ecc build: make npm run build self-sufficient
The ts-cleaner step in the build script scans dist/ and crashes with
ENOENT if the directory doesn't exist (e.g. on a fresh clone or after
git clean). Previously README told users to 'mkdir -p dist' first,
but Dockerfile and CI didn't necessarily follow that. Prepend a small
node one-liner that mkdir's dist recursively before ts-cleaner runs,
and drop the now-redundant manual step from README.
2026-05-26 14:48:30 +09:00
Claude Owner
499852b2a7 fix(tts): avoid char-by-char split when signature list is empty
SignatureClient.regex returned new RegExp("()", "g") when nameSet
was empty (server unreachable, list not yet synced). text.split() with
that regex matches every zero-width position, so 'abc' became
['a','','b','','c',''] and TTSClient sent a separate Chzzk request
per character.

Two-layer fix:
- SignatureClient.regex returns /$^/g (never-match) when names is
  empty, so split() returns the original string as a single element.
- TTSClient.getSource explicitly skips split when nameSet.size === 0
  so the intent is obvious at the call site.
2026-05-26 14:48:24 +09:00
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
17 changed files with 340 additions and 48 deletions

38
.env.example Normal file
View File

@@ -0,0 +1,38 @@
# Discord 봇 토큰
TOKEN=
# Discord 애플리케이션 ID
APPID=
# 명령어 prefix (예: !)
PREFIX=!
# SQLite DB 파일 경로 (예: ./db/tts.db)
DBPATH=./db/tts.db
# 개발 길드 ID. `npm run dev` 시 슬래시 명령이 이 길드에만 등록되어 즉시 반영되고,
# `npm run prod` 실행 시 DEV=true 면 이 길드의 기존 슬래시 명령을 먼저 wipe 한 뒤 전역 등록
GUILDID=
# 치지직(Chzzk) 인증 쿠키
CHZZK_NID_AUT=
CHZZK_NID_SES=
# 시그니처 서버 host:port (선택, 미설정 시 192.168.10.5:2967)
SIGNATURE_HOST=
# TTS 외부 경로 (선택, 현재 호출처 없음. 향후 사용 대비 환경변수만 받아둠)
TTSPATH=
# 개발 모드 플래그 (선택, 기본 false)
# - 봇 기동 시(clientReady): true 면 GUILDID 길드에 슬래시 명령을 길드 한정 등록 (즉시 반영용)
# - `npm run prod` 실행 시: true 면 GUILDID 길드의 기존 슬래시를 먼저 wipe 한 뒤 전역 등록 (중복 정리)
# false 면 전역 등록만 (운영 배포)
DEV=false
# true 면 명령어 오류 스택을 콘솔에 출력 (선택, 기본 false)
DEBUG=false
# 치환 사전(JSON 배열). 기본 사전(def_replaceObj)과 병합 (선택)
# 예: REPLACETEXT=[{"ㅋㅋ":"크크"}]
REPLACETEXT=[{}]

View File

@@ -1,6 +1,8 @@
FROM node:20-alpine FROM node:22-bookworm-slim
RUN apk add --no-cache ffmpeg RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -10,8 +12,6 @@ RUN npm install
COPY . . COPY . .
RUN mkdir -p dist
RUN npm run build RUN npm run build
CMD ["npm", "run", "start"] CMD ["npm", "run", "start"]

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만 정의돼 있고 현재 호출처 없음. 향후 사용 대비 환경변수만 받아둠 |
## 실행 ## 실행
@@ -84,24 +85,14 @@ npm install
# 개발 (ts-node) # 개발 (ts-node)
npm run dev npm run dev
# 빌드 후 실행 (ts-cleaner가 dist/를 스캔하므로 먼저 만들어야 함) # 빌드 후 실행 (build 스크립트가 dist 디렉터리를 자동 생성)
mkdir -p dist npm run build
npm run build # ⚠️ 현재 tsconfig 결함으로 실패 — 아래 "알려진 빌드 결함" 참고
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 +100,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:22-bookworm-slim` 기반이며 `ffmpeg`을 설치한다. (이전 `node:20-alpine` 은 musl 환경이라 `better-sqlite3` prebuilt 가 없어 node-gyp/python 빌드 의존성이 필요했고, `@discordjs/voice` 의 node>=22 요구도 충족하지 못해 변경했다.)
## 동작 흐름 요약 ## 동작 흐름 요약

1
db/db.d.ts vendored
View File

@@ -8,4 +8,5 @@ export interface UserType {
guild_id: string; guild_id: string;
id: string; id: string;
name: string; name: string;
voice_type?: string | null;
} }

View File

@@ -9,9 +9,10 @@ CREATE TABLE IF NOT EXISTS guilds (
); );
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
guild_id TEXT NOT NULL, -- 소속 길드 ID, guilds.id를 참조 guild_id TEXT NOT NULL, -- 소속 길드 ID, guilds.id를 참조
id TEXT NOT NULL, -- 유저 ID id TEXT NOT NULL, -- 유저 ID
name TEXT NOT NULL, -- 유저 이름 (캐싱용) name TEXT NOT NULL, -- 유저 이름 (캐싱용)
voice_type TEXT, -- TTS 목소리 슬러그 (NULL = 기본)
-- 복합 기본키: 같은 길드 안에서 id는 중복 불가 -- 복합 기본키: 같은 길드 안에서 id는 중복 불가
PRIMARY KEY (guild_id, id), PRIMARY KEY (guild_id, id),

View File

@@ -2,20 +2,20 @@
"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",
"type": "commonjs", "type": "commonjs",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"build": "ts-cleaner && tsc", "build": "node -e \"require('fs').mkdirSync('dist',{recursive:true})\" && ts-cleaner && tsc",
"start": "node .", "start": "node .",
"dev": "ts-node src/index.ts", "dev": "ts-node src/index.ts",
"prod": "ts-node src/utils/Prod-commands.ts", "prod": "ts-node src/utils/Prod-commands.ts",
@@ -41,5 +41,8 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"ws": "^8.18.3" "ws": "^8.18.3"
},
"overrides": {
"tar": "^7.5.15"
} }
} }

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;
@@ -31,13 +35,24 @@ export class SignatureClient {
} }
get regex() { get regex() {
const escaped = [...this.nameSet].map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const names = [...this.nameSet];
// 빈 목록이면 new RegExp("()", "g")가 모든 위치에서 매칭돼
// text.split() 결과가 글자 단위로 쪼개진다. 절대 매칭되지 않는
// 패턴을 돌려줘 split 호출자가 원문을 그대로 받게 한다.
if (names.length === 0) return /$^/g;
const escaped = names.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return new RegExp(`(${escaped.join("|")})`, "g"); return new RegExp(`(${escaped.join("|")})`, "g");
} }
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 +69,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

@@ -1,12 +1,17 @@
import { client, signature } from "../index"; import { client, signature } from "../index";
import { ChannelType, Guild, GuildMember, TextChannel, VoiceBasedChannel, VoiceChannel } from "discord.js"; import { ChannelType, Guild, GuildMember, TextChannel, VoiceBasedChannel, VoiceChannel } from "discord.js";
import { Config } from "../utils/Config"; import { Config } from "../utils/Config";
import { DB } from "../utils/Database";
import { Logger } from "../utils/Logger"; import { Logger } from "../utils/Logger";
import { VoiceSession } from "./VoiceSession"; import { VoiceSession } from "./VoiceSession";
import { textToSpeech, VoiceType } from "../utils/tts/Chzzk"; import { textToSpeech, VoiceType } from "../utils/tts/Chzzk";
// import { textToSpeech } from "../utils/tts/Google"; // import { textToSpeech } from "../utils/tts/Google";
// /목소리 명령어로 설정되지 않은 유저에게 사용할 기본 목소리.
export const DEFAULT_VOICE: VoiceType = VoiceType.;
const VOICE_VALUES = new Set<string>(Object.values(VoiceType));
const URL_RE = /https?:\/\/[^\s<>"']+/gi; const URL_RE = /https?:\/\/[^\s<>"']+/gi;
const DOMAIN_LABELS: { test: (h: string) => boolean; label: string }[] = [ const DOMAIN_LABELS: { test: (h: string) => boolean; label: string }[] = [
{ test: h => h === "youtu.be" || h.endsWith("youtube.com"), label: "유튜브 주소", }, { test: h => h === "youtu.be" || h.endsWith("youtube.com"), label: "유튜브 주소", },
@@ -62,7 +67,8 @@ export class TTSClient {
} }
text = this.textEditor(channel.guild, text); text = this.textEditor(channel.guild, text);
const buf = await this.getSorce(text); const voice = this.resolveUserVoice(channel.guild.id, member.id);
const buf = await this.getSource(text, voice);
if (!buf) return; if (!buf) return;
const session = this.getSession(channel.guild, voiceChannel); const session = this.getSession(channel.guild, voiceChannel);
@@ -91,8 +97,20 @@ export class TTSClient {
} }
private async getSorce(text: string): Promise<Buffer | null> { // DB 에 저장된 유저별 목소리를 가져온다. 없거나 알 수 없는 값이면 기본값.
const parts = text.split(signature.regex); private resolveUserVoice(guildId: string, userId: string): VoiceType {
const row = DB.user.get(guildId, userId);
const stored = row?.voice_type;
if (stored && VOICE_VALUES.has(stored)) return stored as VoiceType;
return DEFAULT_VOICE;
}
private async getSource(text: string, voice: VoiceType): Promise<Buffer | null> {
// 시그니처 목록이 비어 있으면 split을 건너뛰고 원문을 한 번에 합성한다.
// (SignatureClient.regex가 빈 목록일 때 never-match 패턴을 돌려주지만,
// 소비자 측에서도 명시적으로 가드해 의도를 분명히 한다.)
const names = signature.nameSet;
const parts = names.size === 0 ? [text] : text.split(signature.regex);
const bufferList: Buffer[] = []; const bufferList: Buffer[] = [];
for (const part of parts) { for (const part of parts) {
@@ -102,11 +120,11 @@ export class TTSClient {
if (buf) { if (buf) {
bufferList.push(buf); bufferList.push(buf);
} else { } else {
const buf = await textToSpeech(this.textReplace(part), VoiceType.).catch(() => null); const buf = await textToSpeech(this.textReplace(part), voice).catch(() => null);
if (buf) bufferList.push(buf); if (buf) bufferList.push(buf);
} }
} else { } else {
const buf = await textToSpeech(this.textReplace(part), VoiceType.).catch(() => null); const buf = await textToSpeech(this.textReplace(part), voice).catch(() => null);
if (buf) bufferList.push(buf); if (buf) bufferList.push(buf);
} }
} }
@@ -119,11 +137,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

@@ -60,6 +60,13 @@ export class VoiceSession {
}, this.endTimeMs); }, this.endTimeMs);
} }
/**
* 새 TTS가 들어오면 이전 재생을 의도적으로 interrupt한다 (큐잉 없음).
* 다중 사용자 환경에서 모든 메시지를 큐로 쌓으면 자기 발화가 수십 초
* 밀려 채팅 맥락과 어긋나므로, "최신 발화 우선, 앞건 잘림"을 정책으로
* 채택했다. generation 카운터는 같은 메시지에 대해 한 번만 play 되도록
* 보호하는 용도이고, 이전 mp3가 잘리는 동작 자체는 설계 의도이다.
*/
public async play(mp3Buf: Buffer) { public async play(mp3Buf: Buffer) {
if (this.isSessionEnd) return; if (this.isSessionEnd) return;
const myGen = ++this.generation; const myGen = ++this.generation;

131
src/commands/voice.ts Normal file
View File

@@ -0,0 +1,131 @@
import { client } from "../index";
import { Command } from "../types/Command";
import { DB } from "../utils/Database";
import { DEFAULT_VOICE } from "../classes/TTSClient";
import { VoiceType } from "../utils/tts/Chzzk";
import {
ActionRowBuilder,
CacheType,
ChannelType,
ChatInputApplicationCommandData,
ChatInputCommandInteraction,
Message,
StringSelectMenuBuilder,
StringSelectMenuInteraction,
} from "discord.js";
// VoiceType 은 enum: 키 = 한글 표시명, 값 = 슬러그.
// Object.entries 의 키 순서는 enum 선언 순서를 따른다.
const VOICE_ENTRIES = Object.entries(VoiceType) as [string, VoiceType][];
const nameOf = (slug: VoiceType): string =>
VOICE_ENTRIES.find(([_, v]) => v === slug)?.[0] ?? slug;
/** /목소리 명령어: 유저 개인별 TTS 목소리를 DB에 저장한다. */
export default class implements Command {
name = "목소리";
visible = true;
aliases: string[] = ["voice"];
description: string = "내 TTS 목소리를 선택합니다.";
metaData: ChatInputApplicationCommandData = {
name: this.name,
description: this.description,
};
async slashRun(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) {
await interaction.editReply({ embeds: [ client.mkembed({
title: "서버 안에서만 사용 가능합니다.",
color: "DarkRed",
}) ] });
return;
}
const row = DB.user.get(interaction.guildId, interaction.user.id);
const current = (row?.voice_type && Object.values(VoiceType).includes(row.voice_type as VoiceType))
? row.voice_type as VoiceType
: DEFAULT_VOICE;
const select = new StringSelectMenuBuilder()
.setCustomId(this.name)
.setPlaceholder("사용할 목소리를 선택해주세요.")
.addOptions(
VOICE_ENTRIES.map(([label, value]) => ({
label,
value,
default: value === current,
}))
);
await interaction.editReply({
embeds: [ client.mkembed({
title: "내 TTS 목소리 설정",
description: [
`현재 목소리: **${nameOf(current)}**`,
"",
"아래에서 원하는 목소리를 선택해주세요.",
"선택은 본인에게만 적용되며, 봇을 재시작해도 유지됩니다.",
].join("\n"),
}) ],
components: [
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select),
],
});
}
async menuRun(interaction: StringSelectMenuInteraction<CacheType>, args: string[]) {
if (!interaction.guildId) {
await interaction.editReply({ embeds: [ client.mkembed({
title: "서버 안에서만 사용 가능합니다.",
color: "DarkRed",
}) ] });
return;
}
const value = args[0];
if (!value || !Object.values(VoiceType).includes(value as VoiceType)) {
await interaction.editReply({ embeds: [ client.mkembed({
title: "알 수 없는 목소리입니다.",
description: "선택지를 다시 확인해주세요.",
color: "DarkRed",
}) ] });
return;
}
// 외래키 제약 대비: 길드 행이 없으면 먼저 생성.
// (/tts channel register 를 한 번도 안 돌린 서버에서도 /목소리 자체는 동작하도록.)
if (!DB.guild.get(interaction.guildId)) {
DB.guild.set({
id: interaction.guildId,
name: interaction.guild?.name ?? "",
channel_id: "",
});
}
const ok = DB.user.setVoice(
interaction.guildId,
interaction.user.id,
interaction.user.username,
value,
);
if (!ok) {
await interaction.editReply({ embeds: [ client.mkembed({
title: "저장 실패",
description: "DB 저장 중 오류가 발생했습니다.",
color: "DarkRed",
}) ] });
return;
}
await interaction.editReply({ embeds: [ client.mkembed({
title: "목소리 설정 완료",
description: `이제부터 **${nameOf(value as VoiceType)}** 목소리로 읽어드립니다.`,
}) ] });
}
async messageRun(message: Message) {
if (message.channel?.type !== ChannelType.GuildText) return;
await message.channel.send({ content: `\`/${this.name}\` (슬래시) 명령어로만 사용할 수 있습니다.` })
.then(m => client.msgDelete(m, 5));
}
}

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

@@ -13,6 +13,15 @@ const schemaPath = join(process.cwd(), "db/schema.sql");
const schema = readFileSync(schemaPath, "utf-8"); const schema = readFileSync(schemaPath, "utf-8");
database.exec(schema); database.exec(schema);
// 마이그레이션: 기존 DB 에 voice_type 컬럼이 없으면 추가
// (CREATE TABLE IF NOT EXISTS 는 이미 존재하는 테이블에 컬럼을 추가하지 않으므로
// PRAGMA 로 직접 확인한다.)
const userCols = database.prepare("PRAGMA table_info(users)").all() as { name: string }[];
if (!userCols.some(c => c.name === "voice_type")) {
database.exec("ALTER TABLE users ADD COLUMN voice_type TEXT");
Logger.ready("DB 마이그레이션: users.voice_type 컬럼 추가");
}
Logger.ready("DB 활성화!"); Logger.ready("DB 활성화!");
const stmt = { const stmt = {
@@ -55,7 +64,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);
}, },
@@ -108,5 +117,21 @@ export const DB = {
return false; return false;
} }
}, },
// 유저 행이 없으면 insert, 있으면 update.
// (sqlite UPSERT 대신 명시적 분기 — 기존 set/update 패턴 유지)
setVoice(guildId: string, id: string, name: string, voiceType: string) {
try {
const existing = stmt.user.get.get(guildId, id) as UserType | undefined;
if (existing) {
stmt.user.update({ guild_id: guildId, id, name, voice_type: voiceType });
} else {
stmt.user.insert({ guild_id: guildId, id, name, voice_type: voiceType });
}
return true;
} catch (err) {
Logger.error(String(err));
return false;
}
},
}, },
}; };

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

@@ -6,6 +6,18 @@ import { Logger } from "./Logger";
async function main() { async function main() {
const rest = new REST({ version: "10" }).setToken(Config.token); const rest = new REST({ version: "10" }).setToken(Config.token);
const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData)); const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData));
// 개발 모드에서는 Config.guildId 길드에 따로 등록된 슬래시가 남아
// 전역 명령어와 디스코드에 두 번 보이는 경우가 생긴다.
// 전역 등록 전에 해당 길드의 명령어를 전부 비워둔다.
if (Config.dev) {
await rest.put(
Routes.applicationGuildCommands(Config.appId, Config.guildId),
{ body: [] },
);
Logger.ready(`개발 길드(${Config.guildId}) 슬래시 전체 삭제 완료`);
}
// 전역 등록 (권장: 배포 파이프라인에서만 실행) // 전역 등록 (권장: 배포 파이프라인에서만 실행)
await rest.put(Routes.applicationCommands(Config.appId), { body }); await rest.put(Routes.applicationCommands(Config.appId), { body });
Logger.ready(`전역 슬래시 등록 요청 완료: ${body.length}`); Logger.ready(`전역 슬래시 등록 요청 완료: ${body.length}`);

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,