feat: Implement a multi-step setup wizard with i18n support and dedicated interaction handlers.

This commit is contained in:
이정수 2026-03-27 15:57:50 +09:00
parent 9f891112d9
commit bdd91f6737
10 changed files with 551 additions and 6 deletions

View File

@ -40,14 +40,14 @@
---
### 2. 권한 검사 (Permission Audit)
### 2. 권한 검사 (Permission Audit)
| 항목 | 내용 |
|------|------|
| **목표** | 봇이 각 기능을 수행하기 위해 충분한 권한을 가지고 있는지 진단하고 보고서를 생성 |
| **트리거** | 슬래시 명령어 (`/audit-permissions` 등) |
| **출력** | 실행한 채널에 Embed 형태의 권한 진단 보고서 전송 |
| **기획서** | `Docs/Plans/Permission_Audit_Plan.md` *(미작성)* |
| **기획서** | [`Permission_Audit_Plan.md`](./Permission_Audit_Plan.md) |
**핵심 고려사항**
- 기능별 필요 권한 매핑 테이블 (Feature → Required Permissions)
@ -57,7 +57,7 @@
---
### 3. 📝 감사 채널 (Audit Log Channel)
### 3. 감사 채널 (Audit Log Channel)
| 항목 | 내용 |
|------|------|
@ -107,14 +107,14 @@
---
### 6. 봇 설정 도우미 (Setup Wizard)
### 6. 봇 설정 도우미 (Setup Wizard)
| 항목 | 내용 |
|------|------|
| **목표** | 서버 관리자가 인터랙션 기반으로 봇의 주요 설정을 단계별로 완료할 수 있는 설정 마법사 제공 |
| **트리거** | 슬래시 명령어 (`/setup` 등) |
| **UI 형태** | Embed + Button + Select Menu 조합의 스텝 바이 스텝 인터랙션 |
| **기획서** | `Docs/Plans/Setup_Wizard_Plan.md` *(미작성)* |
| **기획서** | [`Setup_Wizard_Plan.md`](./Setup_Wizard_Plan.md) |
**핵심 고려사항**
- 설정 항목 정의 (언어, 감사 채널, 임시 음성 채널 생성기 등)

View File

@ -0,0 +1,74 @@
# 봇 설정 도우미 (Setup Wizard) 기획서
## 1. 개요
봇 설정 도우미는 관리자가 Kord 봇을 처음 서버에 추가했거나, 재설정이 필요할 때 직관적인 UI(마법사 형태)를 통해 필수 기능들을 순차적으로 설정할 수 있도록 돕는 기능입니다.
## 2. 진입점 (Trigger)
- **명령어**: `/setup`
- **실행 권한**: `Administrator` 또는 `Manage Guild` 권한 (명령어 자체의 `defaultMemberPermissions`로 제한)
- **실행 위치**: 모든 텍스트 채널 (단, 과정 중 타인의 방해나 혼선을 줄이기 위해 ephemeral(개인만 보기) 형태로 진행)
## 3. 설정 흐름 (Setup Flow)
설정은 스텝(Step) 단위로 진행되며, 각 스텝은 Embed 메시지와 하단 컴포넌트(버튼, Select Menu 등)로 구성됩니다.
사용자는 **[다음]**, **[이번 스텝 건너뛰기]**, **[설정 종료]** 버튼 등으로 흐름을 제어합니다.
### Step 0: 환영 및 소개 (Welcome)
- **내용**: Kord 봇 설정 마법사 시작을 알리고, 설정될 4가지 주요 항목들을 간략히 소개합니다.
(1) 언어 설정, (2) 권한 점검, (3) 감사 채널 지정, (4) 임시 음성 채널 설정
- **액션**: `[시작하기]` 버튼 클릭 시 Step 1로 이동.
### Step 1: 언어 설정 (Language)
- **내용**: 서버의 기본 언어를 설정합니다. (i18n 기능 연동)
- **현재 설정 표시**: 기존에 설정된 언어 표시.
- **컴포넌트**: 지원하는 언어(Korean, English)를 선택할 수 있는 `StringSelectMenu`.
- **액션**:
- 선택 시 즉각적으로 DB 업데이트 및 언어 변경 적용. (이후 UI는 변경된 언어로 릴로드 됨)
- `[다음]` / `[건너뛰기]` 버튼.
### Step 2: 권한 점검 (Permission Check)
- **내용**: 봇이 원활하게 동작하기 위해 필요한 필수 권한(서버 수준)이 부여되어 있는지 점검합니다.
- **컴포넌트**: 점검 통과 상태 (✅ 모두 정상 / ⚠️ 일부 부족). 부족한 경우 권한 부여를 안내합니다. (권한 검사 모듈 `/audit-permissions`의 축소판)
- **액션**: `[다시 검사]` / `[다음]` 버튼.
### Step 3: 감사 채널 설정 (Audit Channel)
- **내용**: 봇의 주요 이벤트와 에러 로그를 남길 시스템 통보 채널을 지정합니다.
- **컴포넌트**:
- 텍스트 채널을 선택하는 `ChannelSelectMenu` (ChannelType.GuildText 채널만).
- `[사용 안함(비활성화)]` 버튼.
- **액션**: 채널 선택 시 DB 갱신 후, `[다음]` 버튼을 눌러 이동 (혹은 선택 즉시 자동 다음 이동 고려).
### Step 4: 임시 음성 채널 설정 (Voice Generator)
- **내용**: 임시 음성 채널 생성 시스템의 진입점이 될 '생성기 채널'을 지정하거나 새로 생성합니다.
- **컴포넌트**:
- 기존 생성할 채널을 고르는 `ChannelSelectMenu` (ChannelType.GuildVoice).
- `[자동 생성]` 버튼: 봇이 새 음성 카테고리와 " 음성 채널 생성" 채널을 자동으로 만들어 줌.
- `[건너뛰기]` 버튼.
- **액션**: 설정 시 즉각 시스템 구동. 이후 `[다음(완료)]` 클릭.
### Step 5: 설정 요약 (Summary)
- **내용**: 지금까지 설정된 모든 항목의 최종 상태를 요약하여 보여줍니다.
- **컴포넌트**: 설정 결과 요약 Embed.
- **액션**: `[설정 마치기]` 버튼 (누르면 "설정이 완료되었습니다"로 메시지 변경 후 버튼 비활성화).
## 4. 아키텍처 (Architecture)
- **State Management (상태 관리)**:
- 마법사는 ephemeral 메시지로 띄워지며, 세션 식별은 `customId` 릴레이 방식을 권장합니다.
- 예시: `setup_action_next_2` (현재 Step 2, 다음으로 이동) 등 Stateless하게 관리하거나,
- 사용자/서버별 임시 Map/Redis에 `SetupSession` (진행 단계 등) 보관. (구현 편의상 `customId` payload에 스텝 인덱스를 담는 Stateless 방식 채택)
- **i18n 통합**:
- 버튼 레이블 및 Embed 내용은 모두 `t()` 함수를 거쳐야 합니다.
- Step 1에서 언어가 변경되면, 즉시 그 언어 기준의 `t()`를 이용해 현재 뷰(View)를 갱신합니다.
## 5. 예외 처리 (Error Handling)
- 설정 진행 중 타임아웃(Discord 기본 15분 경과) 시: Error Guidance 시스템을 통해 "인터랙션이 만료되었습니다, `/setup`을 다시 실행해주세요" 출력.
- 권한 부족: 생성기 `[자동 생성]` 등 채널 생성 로직에서 권한 부족 에러 발생 시 Error Guidance로 팝업 형태(ephemeral) 오류 출력.

27
src/commands/setup.ts Normal file
View File

@ -0,0 +1,27 @@
import {
SlashCommandBuilder,
PermissionFlagsBits,
ChatInputCommandInteraction,
} from 'discord.js';
import { SetupWizardRenderer } from '../services/SetupWizardRenderer';
import { SupportedLocale } from '../i18n';
export default {
data: new SlashCommandBuilder()
.setName('setup')
.setDescription('Run the setup wizard to configure the bot step by step.')
.setDescriptionLocalizations({
ko: '설정 마법사를 실행하여 봇의 필수 기능들을 단계별로 설정합니다.',
})
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
// deferReply is not used because we can just reply directly with the first step View.
const { embed, components } = await SetupWizardRenderer.renderStep(0, interaction, locale);
await interaction.reply({
embeds: [embed],
components,
ephemeral: true,
});
},
};

View File

@ -102,7 +102,7 @@ export default {
},
{
name: t(locale, 'commands.voiceConfig.limitLabel'),
value: t(locale, 'commands.voiceConfig.limitValue', { limit: config?.defaultUserLimit ?? 0 }),
value: t(locale, 'commands.voiceConfig.limitValue', { limit: String(config?.defaultUserLimit ?? 0) }),
inline: true
}
);

View File

@ -7,6 +7,7 @@ import { ErrorDefs, createBotError } from '../errors/ErrorCodes';
import { ErrorReporter, withErrorHandler } from '../errors/ErrorReporter';
import { t } from '../i18n';
import { getInteractionLocale } from '../i18n/localeHelper';
import { handleSetupWizardInteraction } from '../interactions/handlers/setupWizardHandler';
export default {
name: Events.InteractionCreate,
@ -21,6 +22,12 @@ export default {
await command.execute(interaction, locale);
}, locale);
}
else if (interaction.isMessageComponent() && interaction.customId.startsWith('setup_')) {
const locale = await getInteractionLocale(interaction);
await withErrorHandler(interaction, async () => {
await handleSetupWizardInteraction(interaction, locale);
}, locale);
}
else if (interaction.isStringSelectMenu()) {
const customId = interaction.customId;

View File

@ -163,6 +163,52 @@ export const en: TranslationSchema = {
MIMIC_WEBHOOK: 'Message Mimic (Webhook)',
},
},
setup: {
description: 'Run the setup wizard to configure the bot step by step.',
step0: {
title: '✨ Bot Setup Wizard',
desc: 'Welcome! This wizard will help you configure the following 4 features:\n\n1⃣ **Language Settings**\n2⃣ **Permission Check**\n3⃣ **Audit Channel Setup**\n4⃣ **Temporary Voice Channel Setup**',
startBtn: 'Start Setup'
},
step1: {
title: '1⃣ Language Settings',
desc: 'Set the default language for the bot in this server. (Current: **{{locale}}**)',
placeholder: 'Select a language',
nextBtn: 'Next Step',
skipBtn: 'Skip'
},
step2: {
title: '2⃣ Permission Check',
descOk: '✅ **All required permissions are granted.**',
descFail: '⚠️ **Some permissions are missing.**\nPlease check the results and grant the necessary permissions to the bot role.',
recheckBtn: 'Re-check',
nextBtn: 'Next Step'
},
step3: {
title: '3⃣ Audit Channel Setup',
desc: 'Select a channel to receive bot events and error logs.',
placeholder: 'Select Audit Channel',
disableBtn: 'Disable Audit Logs',
nextBtn: 'Next Step'
},
step4: {
title: '4⃣ Temporary Voice Channel Setup',
desc: 'Select the "Generator Channel" for temporary voice channels.\nYou can choose an existing channel or have the bot **auto-create** a new category and channel.',
placeholder: 'Select Generator Channel',
autoBtn: '🚀 Auto Create',
skipBtn: 'Disable Temp Voice',
nextBtn: 'Finish Setup'
},
step5: {
title: '🎉 Setup Summary',
desc: '**1. Language**: {{lang}}\n**2. Audit Channel**: {{audit}}\n**3. Temp Voice**: {{voice}}',
finishBtn: 'Done'
},
finished: '✅ The setup wizard has been finished.',
expired: '⏳ The session has expired. Please run `/setup` again.',
defaultCategoryName: 'Voice Channels',
defaultGeneratorName: ' Create Channel',
},
},
// ── Modals ──────────────────────────────────────────────

View File

@ -163,6 +163,52 @@ export const ko: TranslationSchema = {
MIMIC_WEBHOOK: '메시지 흉내 (Webhook)',
},
},
setup: {
description: '설정 마법사를 실행하여 봇의 필수 기능들을 단계별로 설정합니다.',
step0: {
title: '✨ 봇 설정 마법사 시작',
desc: '환영합니다! 이 마법사를 통해 아래 4가지 항목을 설정합니다.\n\n1⃣ **언어 설정**\n2⃣ **필수 권한 점검**\n3⃣ **감사 채널 설정**\n4⃣ **임시 음성 채널 설정**',
startBtn: '설정 시작하기'
},
step1: {
title: '1⃣ 언어 설정',
desc: '서버 전체에 적용될 봇의 기본 언어를 선택하세요. (현재: **{{locale}}**)',
placeholder: '언어를 선택하세요',
nextBtn: '다음 단계',
skipBtn: '건너뛰기'
},
step2: {
title: '2⃣ 필수 권한 점검',
descOk: '✅ **모든 필수 권한이 정상적으로 부여되어 있습니다.**',
descFail: '⚠️ **일부 권한이 부족합니다.**\n결과를 확인하고 봇 역할에 필요한 기능 권한을 부여해주세요.',
recheckBtn: '다시 검사하기',
nextBtn: '다음 단계'
},
step3: {
title: '3⃣ 감사 채널 설정',
desc: '봇의 주요 이벤트와 에러 통보를 받을 채널을 선택해주세요.',
placeholder: '감사 통보 채널 선택',
disableBtn: '감사 채널 끄기/해제',
nextBtn: '다음 단계'
},
step4: {
title: '4⃣ 임시 음성 채널 설정',
desc: '임시 음성 채널을 생성할 "생성기 채널"을 선택해주세요.\n기존의 채널을 고르거나 카테고리/채널을 봇이 **자동 생성**하게 할 수도 있습니다.',
placeholder: '생성기로 쓸 음성 채널 선택',
autoBtn: '🚀 자동 생성하기',
skipBtn: '임시 음성 사용 안함',
nextBtn: '설정 완료'
},
step5: {
title: '🎉 설정 완료 요약',
desc: '**1. 언어**: {{lang}}\n**2. 감사 채널**: {{audit}}\n**3. 임시 음성 채널**: {{voice}}',
finishBtn: '마치기'
},
finished: '✅ 설정 마법사를 종료했습니다.',
expired: '⏳ 시간이 만료되었습니다. `/setup`을 다시 실행해주세요.',
defaultCategoryName: '음성 채널',
defaultGeneratorName: ' 채널 생성하기',
},
},
// ── 모달 ────────────────────────────────────────────────

View File

@ -117,6 +117,19 @@ export interface TranslationSchema {
MIMIC_WEBHOOK: string;
};
};
setup: {
description: string;
step0: { title: string; desc: string; startBtn: string; };
step1: { title: string; desc: string; placeholder: string; nextBtn: string; skipBtn: string; };
step2: { title: string; descOk: string; descFail: string; recheckBtn: string; nextBtn: string; };
step3: { title: string; desc: string; placeholder: string; disableBtn: string; nextBtn: string; };
step4: { title: string; desc: string; placeholder: string; autoBtn: string; skipBtn: string; nextBtn: string; };
step5: { title: string; desc: string; finishBtn: string; };
finished: string;
expired: string;
defaultCategoryName: string;
defaultGeneratorName: string;
};
};
// ── Modals ──

View File

@ -0,0 +1,143 @@
import { MessageComponentInteraction, PermissionFlagsBits, ChannelType } from 'discord.js';
import { SetupWizardRenderer } from '../../services/SetupWizardRenderer';
import { SupportedLocale, t } from '../../i18n';
import { ErrorDefs, createBotError } from '../../errors/ErrorCodes';
import { prisma } from '../../database';
export async function handleSetupWizardInteraction(interaction: MessageComponentInteraction, locale: SupportedLocale) {
const customId = interaction.customId;
// Validate admin permission
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) && !interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) {
throw createBotError(ErrorDefs.USER_NOT_ADMIN);
}
// Handle finishes & expiration
if (customId === 'setup_finish') {
await interaction.update({
content: t(locale, 'commands.setup.finished'),
embeds: [],
components: [],
});
return;
}
// Next/Refresh Button Routing
if (customId.startsWith('setup_next_') || customId.startsWith('setup_refresh_')) {
const stepMatch = customId.match(/setup_(next|refresh)_(\d+)/);
if (!stepMatch) return;
const targetStep = parseInt(stepMatch[2], 10);
const { embed, components } = await SetupWizardRenderer.renderStep(targetStep, interaction, locale);
await interaction.update({ embeds: [embed], components });
return;
}
// Step 1: Language Select
if (customId === 'setup_lang_select' && interaction.isStringSelectMenu()) {
const selectedLocale = interaction.values[0] as SupportedLocale;
await prisma.guildConfig.upsert({
where: { guildId: interaction.guildId! },
update: { locale: selectedLocale },
create: { guildId: interaction.guildId!, locale: selectedLocale }
});
// Render the next step immediately using the new locale
const { embed, components } = await SetupWizardRenderer.renderStep(2, interaction, selectedLocale);
await interaction.update({ embeds: [embed], components });
return;
}
// Step 3: Audit Channel Select / Disable
if (customId === 'setup_audit_select' && interaction.isChannelSelectMenu()) {
const channelId = interaction.values[0];
await prisma.auditChannel.upsert({
where: { guildId: interaction.guildId! },
update: { channelId },
create: { guildId: interaction.guildId!, channelId }
});
// Auto proceed to next step
const { embed, components } = await SetupWizardRenderer.renderStep(4, interaction, locale);
await interaction.update({ embeds: [embed], components });
return;
}
if (customId === 'setup_audit_disable') {
await prisma.auditChannel.delete({ where: { guildId: interaction.guildId! } }).catch(() => {});
const { embed, components } = await SetupWizardRenderer.renderStep(4, interaction, locale);
await interaction.update({ embeds: [embed], components });
return;
}
// Step 4: Voice Generator Select / Auto / Disable
if (customId === 'setup_voice_select' && interaction.isChannelSelectMenu()) {
const channelId = interaction.values[0];
const channel = await interaction.guild?.channels.fetch(channelId);
if (!channel || channel.type !== ChannelType.GuildVoice) {
throw createBotError(ErrorDefs.INVALID_CHANNEL_NAME); // fallback error
}
// Since channelId is the PK, upsert or deleteMany+create
const existing = await prisma.voiceGenerator.findFirst({ where: { guildId: interaction.guildId! } });
if (existing && existing.channelId !== channelId) {
await prisma.voiceGenerator.delete({ where: { channelId: existing.channelId } }).catch(() => {});
}
await prisma.voiceGenerator.upsert({
where: { channelId: channel.id },
update: { guildId: interaction.guildId!, categoryId: channel.parentId },
create: { channelId: channel.id, guildId: interaction.guildId!, categoryId: channel.parentId }
});
const { embed, components } = await SetupWizardRenderer.renderStep(5, interaction, locale);
await interaction.update({ embeds: [embed], components });
return;
}
if (customId === 'setup_voice_auto') {
if (!interaction.guild) return;
try {
// Defer update because creating channels takes time
await interaction.deferUpdate();
const newCategory = await interaction.guild.channels.create({
name: t(locale, 'commands.setup.defaultCategoryName'),
type: ChannelType.GuildCategory,
});
const newChannel = await interaction.guild.channels.create({
name: t(locale, 'commands.setup.defaultGeneratorName'),
type: ChannelType.GuildVoice,
parent: newCategory.id,
});
const existing = await prisma.voiceGenerator.findFirst({ where: { guildId: interaction.guildId! } });
if (existing) {
await prisma.voiceGenerator.delete({ where: { channelId: existing.channelId } }).catch(() => {});
}
await prisma.voiceGenerator.create({
data: {
channelId: newChannel.id,
guildId: interaction.guildId!,
categoryId: newCategory.id,
}
});
const { embed, components } = await SetupWizardRenderer.renderStep(5, interaction, locale);
await interaction.editReply({ embeds: [embed], components });
} catch (e) {
if ((e as Error).message.includes('Missing Permissions')) {
throw createBotError(ErrorDefs.BOT_MISSING_MANAGE_CHANNELS, e as Error);
}
throw e;
}
return;
}
if (customId === 'setup_voice_disable') {
await prisma.voiceGenerator.deleteMany({ where: { guildId: interaction.guildId! } });
const { embed, components } = await SetupWizardRenderer.renderStep(5, interaction, locale);
await interaction.update({ embeds: [embed], components });
return;
}
}

View File

@ -0,0 +1,189 @@
import {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
StringSelectMenuBuilder,
ChannelSelectMenuBuilder,
ChannelType,
MessageComponentInteraction,
ChatInputCommandInteraction,
Colors,
} from 'discord.js';
import { SupportedLocale, t } from '../i18n';
import { PermissionAuditService } from './PermissionAuditService';
import { prisma } from '../database';
export class SetupWizardRenderer {
static async renderStep(
step: number,
interaction: MessageComponentInteraction | ChatInputCommandInteraction,
locale: SupportedLocale
): Promise<{ embed: EmbedBuilder; components: ActionRowBuilder<any>[] }> {
const embed = new EmbedBuilder().setColor(Colors.Blurple);
const components: ActionRowBuilder<any>[] = [];
switch (step) {
case 0: {
embed.setTitle(t(locale, 'commands.setup.step0.title'))
.setDescription(t(locale, 'commands.setup.step0.desc'));
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('setup_next_1')
.setLabel(t(locale, 'commands.setup.step0.startBtn'))
.setStyle(ButtonStyle.Primary)
);
components.push(row);
break;
}
case 1: {
embed.setTitle(t(locale, 'commands.setup.step1.title'))
.setDescription(t(locale, 'commands.setup.step1.desc', { locale: locale === 'ko' ? 'Korean' : 'English' }));
const select = new StringSelectMenuBuilder()
.setCustomId('setup_lang_select')
.setPlaceholder(t(locale, 'commands.setup.step1.placeholder'))
.addOptions([
{ label: 'Korean', value: 'ko', description: '한국어로 봇을 설정합니다.' },
{ label: 'English', value: 'en', description: 'Set bot to English.' }
]);
const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('setup_next_2')
.setLabel(t(locale, 'commands.setup.step1.nextBtn'))
.setStyle(ButtonStyle.Secondary)
);
components.push(new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select));
components.push(btnRow);
break;
}
case 2: {
if (!interaction.guild) throw new Error('Guild not found');
const results = await PermissionAuditService.auditGuild(interaction.guild);
const hasFails = results.some(r => r.status === 'FAIL');
embed.setTitle(t(locale, 'commands.setup.step2.title'));
if (hasFails) {
embed.setDescription(t(locale, 'commands.setup.step2.descFail'))
.setColor(Colors.Red);
// ⚠️ 권한 부족 목록 렌더링
const failLines = results
.filter(r => r.status === 'FAIL')
.map(r => `- **${t(locale, `commands.permissionAudit.features.${r.featureKey as any}`) || r.featureKey}**\n > \`${r.missingPermissions.join('`, `')}\``);
if (failLines.length > 0) {
embed.addFields({ name: 'Missing Permissions', value: failLines.join('\n') });
}
} else {
embed.setDescription(t(locale, 'commands.setup.step2.descOk'))
.setColor(Colors.Green);
}
const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('setup_refresh_2')
.setLabel(t(locale, 'commands.setup.step2.recheckBtn'))
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId('setup_next_3')
.setLabel(t(locale, 'commands.setup.step2.nextBtn'))
.setStyle(hasFails ? ButtonStyle.Danger : ButtonStyle.Primary)
);
components.push(btnRow);
break;
}
case 3: {
embed.setTitle(t(locale, 'commands.setup.step3.title'))
.setDescription(t(locale, 'commands.setup.step3.desc'));
const select = new ChannelSelectMenuBuilder()
.setCustomId('setup_audit_select')
.setPlaceholder(t(locale, 'commands.setup.step3.placeholder'))
.setChannelTypes(ChannelType.GuildText);
const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('setup_audit_disable')
.setLabel(t(locale, 'commands.setup.step3.disableBtn'))
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId('setup_next_4')
.setLabel(t(locale, 'commands.setup.step3.nextBtn'))
.setStyle(ButtonStyle.Secondary)
);
components.push(new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(select));
components.push(btnRow);
break;
}
case 4: {
embed.setTitle(t(locale, 'commands.setup.step4.title'))
.setDescription(t(locale, 'commands.setup.step4.desc'));
const select = new ChannelSelectMenuBuilder()
.setCustomId('setup_voice_select')
.setPlaceholder(t(locale, 'commands.setup.step4.placeholder'))
.setChannelTypes(ChannelType.GuildVoice);
const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('setup_voice_auto')
.setLabel(t(locale, 'commands.setup.step4.autoBtn'))
.setStyle(ButtonStyle.Success),
new ButtonBuilder()
.setCustomId('setup_voice_disable')
.setLabel(t(locale, 'commands.setup.step4.skipBtn'))
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId('setup_next_5')
.setLabel(t(locale, 'commands.setup.step4.nextBtn'))
.setStyle(ButtonStyle.Secondary)
);
components.push(new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(select));
components.push(btnRow);
break;
}
case 5: {
if (!interaction.guild) throw new Error('Guild not found');
const config = await prisma.guildConfig.findUnique({ where: { guildId: interaction.guild.id } });
const audit = await prisma.auditChannel.findUnique({ where: { guildId: interaction.guild.id } });
const voice = await prisma.voiceGenerator.findFirst({ where: { guildId: interaction.guild.id } });
embed.setTitle(t(locale, 'commands.setup.step5.title'))
.setColor(Colors.Green);
const langStr = config?.locale === 'ko' ? 'Korean' : 'English';
const auditStr = audit?.channelId ? `<#${audit.channelId}>` : 'Disabled';
const voiceStr = voice?.channelId ? `<#${voice.channelId}>` : 'Disabled';
embed.setDescription(t(locale, 'commands.setup.step5.desc', {
lang: langStr,
audit: auditStr,
voice: voiceStr
}));
const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('setup_finish')
.setLabel(t(locale, 'commands.setup.step5.finishBtn'))
.setStyle(ButtonStyle.Success)
);
components.push(btnRow);
break;
}
}
return { embed, components };
}
}