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.
This commit is contained in:
Claude Owner
2026-05-26 22:50:53 +09:00
parent 2002b1cc12
commit 1841828f7a
5 changed files with 179 additions and 7 deletions

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