diff --git a/Docs/Plans/Feature_Roadmap.md b/Docs/Plans/Feature_Roadmap.md index a8f5281..aa8a184 100644 --- a/Docs/Plans/Feature_Roadmap.md +++ b/Docs/Plans/Feature_Roadmap.md @@ -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) | **핵심 고려사항** - 설정 항목 정의 (언어, 감사 채널, 임시 음성 채널 생성기 등) diff --git a/Docs/Plans/Setup_Wizard_Plan.md b/Docs/Plans/Setup_Wizard_Plan.md new file mode 100644 index 0000000..dffe20b --- /dev/null +++ b/Docs/Plans/Setup_Wizard_Plan.md @@ -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) 오류 출력. diff --git a/src/commands/setup.ts b/src/commands/setup.ts new file mode 100644 index 0000000..8f47851 --- /dev/null +++ b/src/commands/setup.ts @@ -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, + }); + }, +}; diff --git a/src/commands/voiceConfig.ts b/src/commands/voiceConfig.ts index f9016a9..ac36a57 100644 --- a/src/commands/voiceConfig.ts +++ b/src/commands/voiceConfig.ts @@ -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 } ); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index f7d2df1..600eb81 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -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; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 389f684..b280f87 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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 ────────────────────────────────────────────── diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 9536ef6..771e13f 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -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: '➕ 채널 생성하기', + }, }, // ── 모달 ──────────────────────────────────────────────── diff --git a/src/i18n/types.ts b/src/i18n/types.ts index d4fec4a..42f6a7c 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -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 ── diff --git a/src/interactions/handlers/setupWizardHandler.ts b/src/interactions/handlers/setupWizardHandler.ts new file mode 100644 index 0000000..265ccc4 --- /dev/null +++ b/src/interactions/handlers/setupWizardHandler.ts @@ -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; + } +} diff --git a/src/services/SetupWizardRenderer.ts b/src/services/SetupWizardRenderer.ts new file mode 100644 index 0000000..1a5201d --- /dev/null +++ b/src/services/SetupWizardRenderer.ts @@ -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[] }> { + const embed = new EmbedBuilder().setColor(Colors.Blurple); + const components: ActionRowBuilder[] = []; + + switch (step) { + case 0: { + embed.setTitle(t(locale, 'commands.setup.step0.title')) + .setDescription(t(locale, 'commands.setup.step0.desc')); + + const row = new ActionRowBuilder().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().addComponents( + new ButtonBuilder() + .setCustomId('setup_next_2') + .setLabel(t(locale, 'commands.setup.step1.nextBtn')) + .setStyle(ButtonStyle.Secondary) + ); + + components.push(new ActionRowBuilder().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().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().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().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().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().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().addComponents( + new ButtonBuilder() + .setCustomId('setup_finish') + .setLabel(t(locale, 'commands.setup.step5.finishBtn')) + .setStyle(ButtonStyle.Success) + ); + + components.push(btnRow); + break; + } + } + + return { embed, components }; + } +}