Compare commits

..

8 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
12 changed files with 257 additions and 17 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

@@ -85,8 +85,7 @@ 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
npm start npm start
@@ -101,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

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

@@ -35,7 +35,12 @@ 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");
} }

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.getSource(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 getSource(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);
} }
} }

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

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