diff --git a/db/db.d.ts b/db/db.d.ts index 9690760..ec50eb4 100644 --- a/db/db.d.ts +++ b/db/db.d.ts @@ -8,4 +8,5 @@ export interface UserType { guild_id: string; id: string; name: string; + voice_type?: string | null; } \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index 2d50dba..86605c2 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -9,9 +9,10 @@ CREATE TABLE IF NOT EXISTS guilds ( ); CREATE TABLE IF NOT EXISTS users ( - guild_id TEXT NOT NULL, -- 소속 길드 ID, guilds.id를 참조 - id TEXT NOT NULL, -- 유저 ID - name TEXT NOT NULL, -- 유저 이름 (캐싱용) + guild_id TEXT NOT NULL, -- 소속 길드 ID, guilds.id를 참조 + id TEXT NOT NULL, -- 유저 ID + name TEXT NOT NULL, -- 유저 이름 (캐싱용) + voice_type TEXT, -- TTS 목소리 슬러그 (NULL = 기본) -- 복합 기본키: 같은 길드 안에서 id는 중복 불가 PRIMARY KEY (guild_id, id), diff --git a/src/classes/TTSClient.ts b/src/classes/TTSClient.ts index 26fa809..050e2bc 100644 --- a/src/classes/TTSClient.ts +++ b/src/classes/TTSClient.ts @@ -1,12 +1,17 @@ import { client, signature } from "../index"; import { ChannelType, Guild, GuildMember, TextChannel, VoiceBasedChannel, VoiceChannel } from "discord.js"; import { Config } from "../utils/Config"; +import { DB } from "../utils/Database"; import { Logger } from "../utils/Logger"; import { VoiceSession } from "./VoiceSession"; import { textToSpeech, VoiceType } from "../utils/tts/Chzzk"; // import { textToSpeech } from "../utils/tts/Google"; +// /목소리 명령어로 설정되지 않은 유저에게 사용할 기본 목소리. +export const DEFAULT_VOICE: VoiceType = VoiceType.가람; +const VOICE_VALUES = new Set(Object.values(VoiceType)); + 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: "유튜브 주소", }, @@ -62,7 +67,8 @@ export class TTSClient { } 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; const session = this.getSession(channel.guild, voiceChannel); @@ -91,7 +97,15 @@ export class TTSClient { } - private async getSource(text: string): Promise { + // DB 에 저장된 유저별 목소리를 가져온다. 없거나 알 수 없는 값이면 기본값. + 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 { // 시그니처 목록이 비어 있으면 split을 건너뛰고 원문을 한 번에 합성한다. // (SignatureClient.regex가 빈 목록일 때 never-match 패턴을 돌려주지만, // 소비자 측에서도 명시적으로 가드해 의도를 분명히 한다.) @@ -106,11 +120,11 @@ export class TTSClient { if (buf) { bufferList.push(buf); } 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); } } 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); } } diff --git a/src/commands/voice.ts b/src/commands/voice.ts new file mode 100644 index 0000000..736f9c0 --- /dev/null +++ b/src/commands/voice.ts @@ -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().addComponents(select), + ], + }); + } + + async menuRun(interaction: StringSelectMenuInteraction, 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)); + } +} diff --git a/src/utils/Database.ts b/src/utils/Database.ts index b02d7c4..6b77991 100644 --- a/src/utils/Database.ts +++ b/src/utils/Database.ts @@ -13,6 +13,15 @@ const schemaPath = join(process.cwd(), "db/schema.sql"); const schema = readFileSync(schemaPath, "utf-8"); 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 활성화!"); const stmt = { @@ -108,5 +117,21 @@ export const DB = { 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; + } + }, }, }; \ No newline at end of file