diff --git a/.agents/rules/kord_routine.md b/.agents/rules/kord_routine.md index dc18eef..c4e5166 100644 --- a/.agents/rules/kord_routine.md +++ b/.agents/rules/kord_routine.md @@ -32,6 +32,7 @@ description: work routine - 동시 실행 시 충돌을 방지하기 위해, 테스트 구동 시에는 반드시 **임의의 고유한 `INSTANCE_ID` 환경 변수를 할당**(`INSTANCE_ID=agent-test-xxxx yarn run dev`)하여 기존 인스턴스와 락(Lock)이나 로그가 겹치지 않게 합니다. - 필요하다면 단위 테스트(`yarn test`)를 실행합니다. - 오류 발생 시 자체적으로 판단하고 디버깅하여 해결합니다. +- 테스트가 완료되면 실행한 인스턴스를 종료합니다. ### 4단계: 사용자 최종 테스트 지원 (Final Manual Testing) diff --git a/Docs/WorkDone/2026-03-30_HierarchicalRefactor.md b/Docs/WorkDone/2026-03-30_HierarchicalRefactor.md new file mode 100644 index 0000000..78b7e3e --- /dev/null +++ b/Docs/WorkDone/2026-03-30_HierarchicalRefactor.md @@ -0,0 +1,53 @@ +# Gathered Command UI Refactoring (2026-03-30) + +## 작업 개요 (Work Summary) + +디스코드 슬래시 명령어의 자동완성 목록이 파편화되는 문제를 해결하기 위해, 모든 명령어를 **2단계 계층 구조(Command -> Subcommand)**로 일원화하고 상세 동작은 **옵션(Option Choices)**으로 선택하는 '모아보기(Gathered UI)' 형태로 전면 리팩토링했습니다. + +## 주요 변경 사항 (Key Changes) + +### 1. 개발 규칙 업데이트 + +- **파일**: `.agents/rules/kord_routine.md` +- **변경**: `Subcommand Group` 대신 `Subcommand -> Option Choices` 패턴을 사용하여 자동완성 목록을 오염시키지 않도록 규칙 수정. + +### 2. `/setup` 명령어 독립 (Standalone Wizard) + +- **개요**: 봇의 초기 환경 설정을 위한 '마법사' 기능을 상징적인 중요도에 따라 별도의 최상위 명령어로 분리했습니다. +- **구조**: `/setup` (세부 옵션 없음) + +### 3. `/config` 명령어 리팩토링 (Gathered UI) + +- **구조**: `/config [subcommand] action:[choices] [options...]` +- **세부 사항**: + - `/config system action:language locale:[en/ko]` (서버 전체 언어 설정) + - `/config features target:[mimic/emoji] enable:[bool]` (각 기능 활성화 여부) + - *비고: 기존의 setup 과정은 독립된 `/setup` 명령어를 통해 제공됩니다.* + +### 4. `/audit` 명령어 리팩토링 (Gathered) + +- **구조**: `/audit [subcommand] action:[choices] [options...]` +- **세부 사항**: + - `/audit channel action:set channel:[#channel]` (채널 지정) + - `/audit channel action:clear` (채널 해제) + - `/audit channel action:status` (상태 확인) + - `/audit channel action:filter category:[...] enable:[bool]` (필터링) + - `/audit bot action:permissions` (권한 진단) + +### 5. `/voice` 명령어 리팩토링 (Gathered) + +- **구조**: `/voice [subcommand] action:[choices] [options...]` +- **세부 사항**: + - `/voice generator action:set/create [...]` (생성기 설정) + - `/voice settings action:name/limit/status [...]` (채널 설정) + +## 의사 결정 (Decisions Made) + +- **2단계 고정**: 디스코드 입력창에 `/voice`만 쳤을 때 `generator`와 `settings`만 딱 나타나게 하여 시각적 복잡도를 최소화함. +- **동적 옵션 처리**: 특정 `action`을 골랐을 때만 필요한 옵션(예: `locale`)이 뒤따라 수동으로 안내되도록 로직 구성 (필수 옵션 미입력 시 에러 메시지 처리). + +## 결과 및 테스트 (Results & Testing) + +- `yarn build` 성공 (Type safety 확인) +- 모든 `interaction.options.getSubcommand()` 분기 처리 완료. +- **주의**: 명령어 스키마가 변경되었으므로, 디스코드가 전역 명령어를 갱신(최대 1시간 소요)할 때까지 대기하거나 봇을 재구동해야 함. diff --git a/Docs/index.md b/Docs/index.md index cd7d4fd..6ca644b 100644 --- a/Docs/index.md +++ b/Docs/index.md @@ -47,3 +47,4 @@ - [2026-03-27: 권한 진단 기능 구현 (Permission Audit Implementation)](WorkDone/2026-03-27_Permission_Audit_Implementation.md) - [2026-03-27: 에러 안내 UX 개선 및 통합 (Error Guidance UX Implementation)](WorkDone/2026-03-27_Error_Guidance_UX_Implementation.md) - [2026-03-27: Kord 프로젝트 초기 설정 (Project Initial Setup)](WorkDone/2026-03-27_Project_Initial_Setup.md) +- [2026-03-30: 명령어 계층 구조 리팩토링 (Hierarchical Command Refactoring)](WorkDone/2026-03-30_HierarchicalRefactor.md) diff --git a/src/commands/audit.ts b/src/commands/audit.ts index fd61d18..c680994 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -52,83 +52,75 @@ export default { ko: '감사 로그 및 봇 권한을 관리합니다.', }) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - // --- Channel Subcommand Group --- - .addSubcommandGroup(group => - group + // --- Channel Subcommand --- + .addSubcommand(subcommand => + subcommand .setName('channel') .setDescription('Manage audit log channel settings.') .setDescriptionLocalizations({ ko: '감사 채널 설정을 관리합니다.' }) - .addSubcommand(subcommand => - subcommand - .setName('set') - .setDescription('Set the audit log channel.') - .setDescriptionLocalizations({ ko: '감사 채널을 설정합니다.' }) - .addChannelOption(option => - option.setName('channel') - .setDescription('The text channel to use for audit logs.') - .setDescriptionLocalizations({ ko: '감사 로그를 기록할 텍스트 채널' }) - .addChannelTypes(ChannelType.GuildText) - .setRequired(true) + .addStringOption(option => + option.setName('action') + .setDescription('Action to perform') + .setRequired(true) + .addChoices( + { name: 'set (Set Channel)', value: 'set' }, + { name: 'clear (Reset)', value: 'clear' }, + { name: 'status (Check)', value: 'status' }, + { name: 'filter (Toggle Categories)', value: 'filter' }, ) ) - .addSubcommand(subcommand => - subcommand - .setName('clear') - .setDescription('Clear the audit log channel.') - .setDescriptionLocalizations({ ko: '감사 채널 설정을 해제합니다.' }) + .addChannelOption(option => + option.setName('channel') + .setDescription('Channel to use (for set action)') + .addChannelTypes(ChannelType.GuildText) ) - .addSubcommand(subcommand => - subcommand - .setName('status') - .setDescription('Check current audit log channel status.') - .setDescriptionLocalizations({ ko: '현재 감사 채널 설정 상태를 확인합니다.' }) + .addStringOption(option => + option.setName('category') + .setDescription('Category to toggle (for filter action)') + .addChoices( + { name: 'SYSTEM', value: 'SYSTEM' }, + { name: 'BOOT', value: 'BOOT' }, + { name: 'VOICE', value: 'VOICE' }, + { name: 'PERMISSION', value: 'PERMISSION' }, + { name: 'INVITE', value: 'INVITE' }, + { name: 'MIMIC', value: 'MIMIC' } + ) ) - .addSubcommand(subcommand => - subcommand - .setName('filter') - .setDescription('Enable or disable specific audit log categories.') - .setDescriptionLocalizations({ ko: '특정 종류의 감사 로그 수신 여부를 설정합니다.' }) - .addStringOption(option => - option.setName('category') - .setDescription('The category to manage') - .setDescriptionLocalizations({ ko: '설정할 카테고리' }) - .setRequired(true) - .addChoices( - { name: 'SYSTEM (System Errors)', value: 'SYSTEM' }, - { name: 'BOOT (Bot Online Notifications)', value: 'BOOT' }, - { name: 'VOICE (Voice Channels)', value: 'VOICE' }, - { name: 'PERMISSION (Permission Issues)', value: 'PERMISSION' }, - { name: 'INVITE (Invite Tracking)', value: 'INVITE' }, - { name: 'MIMIC (Mimic Features)', value: 'MIMIC' } - ) - ) - .addBooleanOption(option => - option.setName('enable') - .setDescription('True to receive logs for this category, False to ignore.') - .setDescriptionLocalizations({ ko: '해당 카테고리 수신 (true) 또는 차단 (false)' }) - .setRequired(true) - ) + .addBooleanOption(option => + option.setName('enable') + .setDescription('Enable or disable (for filter action)') ) ) - // --- Permissions Subcommand --- + // --- Bot Subcommand --- .addSubcommand(subcommand => subcommand - .setName('permissions') - .setDescription('Check if the bot has all required permissions for its features.') - .setDescriptionLocalizations({ ko: '봇이 필요한 권한을 가지고 있는지 진단합니다.' }) + .setName('bot') + .setDescription('Diagnose and manage bot-specific status.') + .setDescriptionLocalizations({ ko: '봇의 상태를 진단하고 관리합니다.' }) + .addStringOption(option => + option.setName('action') + .setDescription('Action to perform') + .setRequired(true) + .addChoices( + { name: 'permissions (Diagnose)', value: 'permissions' }, + ) + ) ), async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { - const group = interaction.options.getSubcommandGroup(); const subcommand = interaction.options.getSubcommand(); const guild = interaction.guild!; try { - // --- CHANNEL GROUP --- - if (group === 'channel') { - if (subcommand === 'set') { + // --- CHANNEL --- + if (subcommand === 'channel') { + const action = interaction.options.getString('action', true); + + if (action === 'set') { + const channel = interaction.options.getChannel('channel') as TextChannel; + if (!channel) return interaction.reply({ content: '❌ `channel` 옵션을 선택해주세요.', ephemeral: true }); + await interaction.deferReply({ ephemeral: true }); - const channel = interaction.options.getChannel('channel', true) as TextChannel; const botMember = guild.members.me; if (!botMember) return; const perms = channel.permissionsFor(botMember); @@ -148,16 +140,16 @@ export default { return; } - if (subcommand === 'clear') { + if (action === 'clear') { await interaction.deferReply({ ephemeral: true }); await auditLogService.clearChannel(guild.id); return interaction.editReply({ content: `✅ 감사 채널 설정이 해제되었습니다.` }); } - if (subcommand === 'status') { + if (action === 'status') { await interaction.deferReply({ ephemeral: true }); const config = await auditLogService.getChannel(guild.id); - if (!config) return interaction.editReply({ content: `설정된 감사 채널이 없습니다. 먼저 \`/audit channel set\` 명령어로 채널을 설정해주세요.` }); + if (!config) return interaction.editReply({ content: `설정된 감사 채널이 없습니다. 먼저 \`/audit channel action:set\` 명령어로 채널을 설정해주세요.` }); const disabled = config.disabledCategories.length > 0 ? config.disabledCategories.map((c: string) => `\`${c}\``).join(', ') : '없음 (모두 수신 중)'; const embed = new EmbedBuilder() @@ -171,62 +163,70 @@ export default { return interaction.editReply({ embeds: [embed] }); } - if (subcommand === 'filter') { - await interaction.deferReply({ ephemeral: true }); - const category = interaction.options.getString('category', true) as AuditCategory; - const enable = interaction.options.getBoolean('enable', true); + if (action === 'filter') { + const category = interaction.options.getString('category') as AuditCategory; + const enable = interaction.options.getBoolean('enable'); + if (!category || enable === null) { + return interaction.reply({ content: '❌ `category` 및 `enable` 옵션을 모두 입력해주세요.', ephemeral: true }); + } + + await interaction.deferReply({ ephemeral: true }); const config = await auditLogService.getChannel(guild.id); - if (!config) return interaction.editReply({ content: `설정된 감사 채널이 없습니다. 먼저 \`/audit channel set\` 명령어로 채널을 설정해주세요.` }); + if (!config) return interaction.editReply({ content: `설정된 감사 채널이 없습니다. 먼저 \`/audit channel action:set\` 명령어로 채널을 설정해주세요.` }); await auditLogService.setFilter(guild.id, category, enable); return interaction.editReply({ content: `✅ **${category}** 카테고리의 감사 로그 수신이 **${enable ? '활성화(ON)' : '비활성화(OFF)'}** 되었습니다.` }); } } - // --- PERMISSIONS SUBCOMMAND --- - if (subcommand === 'permissions') { - await interaction.deferReply({ ephemeral: true }); - const results = await PermissionAuditService.auditGuild(guild); - if (results.length === 0) return interaction.editReply({ content: t(locale, 'commands.permissionAudit.noResults') }); + // --- BOT --- + if (subcommand === 'bot') { + const action = interaction.options.getString('action', true); - const sorted = [...results].sort((a, b) => { - const order: Record = { FAIL: 0, WARNING: 1, SUCCESS: 2 }; - return order[a.status] - order[b.status]; - }); + if (action === 'permissions') { + await interaction.deferReply({ ephemeral: true }); + const results = await PermissionAuditService.auditGuild(guild); + if (results.length === 0) return interaction.editReply({ content: t(locale, 'commands.permissionAudit.noResults') }); - const lines = sorted.map((r) => buildResultLine(r, locale)); - const failCount = results.filter((r) => r.status === 'FAIL').length; - const warnCount = results.filter((r) => r.status === 'WARNING').length; - - let summary = failCount === 0 && warnCount === 0 ? t(locale, 'commands.permissionAudit.summaryOk') : t(locale, 'commands.permissionAudit.summaryIssue', { fail: String(failCount), warn: String(warnCount) }); - - const embed = new EmbedBuilder() - .setTitle(t(locale, 'commands.permissionAudit.title')) - .setDescription(lines.join('\n\n')) - .addFields({ name: t(locale, 'commands.permissionAudit.summaryLabel'), value: summary }) - .setColor(getOverallColor(results)) - .setTimestamp(); - - await interaction.editReply({ embeds: [embed] }); - - if (failCount > 0 || warnCount > 0) { - const issues = sorted.filter(r => r.status !== 'SUCCESS'); - const descLines = issues.map(r => { - let text = `- **${r.featureKey}** [${r.status}]`; - if (r.missingPermissions.length > 0) text += `\n 누락: \`${r.missingPermissions.join('`, `')}\``; - if (r.scope === 'channel') text += `\n 채널: <#${r.channelId}>`; - return text; + const sorted = [...results].sort((a, b) => { + const order: Record = { FAIL: 0, WARNING: 1, SUCCESS: 2 }; + return order[a.status] - order[b.status]; }); - await auditLogService.log(guild, { - category: 'PERMISSION', - severity: failCount > 0 ? 'ERROR' : 'WARN', - title: '권한 감사에서 문제 감지', - description: `\`/audit permissions\` 실행 중 권한 문제가 확인되었습니다.\n\n${descLines.join('\n')}` - }).catch(() => {}); + const lines = sorted.map((r) => buildResultLine(r, locale)); + const failCount = results.filter((r) => r.status === 'FAIL').length; + const warnCount = results.filter((r) => r.status === 'WARNING').length; + + let summary = failCount === 0 && warnCount === 0 ? t(locale, 'commands.permissionAudit.summaryOk') : t(locale, 'commands.permissionAudit.summaryIssue', { fail: String(failCount), warn: String(warnCount) }); + + const embed = new EmbedBuilder() + .setTitle(t(locale, 'commands.permissionAudit.title')) + .setDescription(lines.join('\n\n')) + .addFields({ name: t(locale, 'commands.permissionAudit.summaryLabel'), value: summary }) + .setColor(getOverallColor(results)) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + + if (failCount > 0 || warnCount > 0) { + const issues = sorted.filter(r => r.status !== 'SUCCESS'); + const descLines = issues.map(r => { + let text = `- **${r.featureKey}** [${r.status}]`; + if (r.missingPermissions.length > 0) text += `\n 누락: \`${r.missingPermissions.join('`, `')}\``; + if (r.scope === 'channel') text += `\n 채널: <#${r.channelId}>`; + return text; + }); + + await auditLogService.log(guild, { + category: 'PERMISSION', + severity: failCount > 0 ? 'ERROR' : 'WARN', + title: '권한 감사에서 문제 감지', + description: `\`/audit bot action:permissions\` 실행 중 권한 문제가 확인되었습니다.\n\n${descLines.join('\n')}` + }).catch(() => {}); + } + return; } - return; } } catch (error) { console.error('Error in audit command', error); @@ -235,3 +235,5 @@ export default { } }, }; + + diff --git a/src/commands/config.ts b/src/commands/config.ts index 599156e..3d687c2 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,63 +1,141 @@ -import { SlashCommandBuilder, ChatInputCommandInteraction, PermissionFlagsBits, EmbedBuilder } from 'discord.js'; +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + PermissionFlagsBits, + EmbedBuilder, + Colors +} from 'discord.js'; import { prisma } from '../database'; -import { t, resolveLocale } from '../i18n'; +import { t, SupportedLocale, SUPPORTED_LOCALES } from '../i18n'; export default { data: new SlashCommandBuilder() .setName('config') - .setDescription('Configure bot features') + .setDescription('Configure bot features and server settings.') + .setDescriptionLocalizations({ + ko: '봇의 기능 및 서버 설정을 관리합니다.', + }) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .addBooleanOption(opt => opt.setName('mimic').setDescription('Enable or disable Mimic feature')) - .addBooleanOption(opt => opt.setName('emoji').setDescription('Enable or disable Big Emoji feature')), + // --- System Subcommand --- + .addSubcommand(subcommand => + subcommand + .setName('system') + .setDescription('Manage core system settings.') + .setDescriptionLocalizations({ ko: '시스템 핵심 설정을 관리합니다.' }) + .addStringOption(option => + option.setName('action') + .setDescription('System action to perform') + .setRequired(true) + .addChoices( + { name: 'language (Server Locale)', value: 'language' }, + ) + ) + .addStringOption(option => + option.setName('locale') + .setDescription('Language to use (for language action)') + .addChoices( + { name: 'English', value: 'en' }, + { name: '한국어', value: 'ko' }, + ) + ) + ) + // --- Features Subcommand --- + .addSubcommand(subcommand => + subcommand + .setName('features') + .setDescription('Enable or disable specific bot features.') + .setDescriptionLocalizations({ + ko: '봇의 특정 기능들을 활성화 또는 비활성화합니다.', + }) + .addStringOption(option => + option.setName('target') + .setDescription('Feature to configure') + .setRequired(true) + .addChoices( + { name: 'mimic', value: 'mimic' }, + { name: 'emoji', value: 'emoji' }, + ) + ) + .addBooleanOption(option => + option.setName('enable') + .setDescription('Whether to enable this feature') + .setRequired(true) + ) + ), - async execute(interaction: ChatInputCommandInteraction) { + async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { if (!interaction.guildId) return; + const subcommand = interaction.options.getSubcommand(); - const mimic = interaction.options.getBoolean('mimic'); - const emoji = interaction.options.getBoolean('emoji'); + // --- SYSTEM --- + if (subcommand === 'system') { + const action = interaction.options.getString('action', true); - // Resolve proper supported locale - const locale = resolveLocale({ - discordLocale: interaction.locale, - guildLocale: interaction.guildLocale?.toString(), - }); + // Removed 'setup' action (now in standalone /setup command) - if (mimic === null && emoji === null) { - return interaction.reply({ - content: t(locale, 'commands.config.noOptions'), - ephemeral: true + if (action === 'language') { + const newLocale = interaction.options.getString('locale') as SupportedLocale; + if (!newLocale) { + return interaction.reply({ + content: '❌ `locale` 옵션을 선택해주세요.', + ephemeral: true + }); + } + + if (!SUPPORTED_LOCALES.includes(newLocale)) { + return interaction.reply({ content: `Unsupported locale: ${newLocale}`, ephemeral: true }); + } + + await prisma.guildConfig.upsert({ + where: { guildId: interaction.guildId }, + update: { locale: newLocale }, + create: { guildId: interaction.guildId, locale: newLocale }, + }); + + return interaction.reply({ + content: t(newLocale, 'commands.language.serverSet', { locale: newLocale === 'ko' ? '한국어' : 'English' }), + ephemeral: true, + }); + } + } + + // --- FEATURES --- + if (subcommand === 'features') { + const target = interaction.options.getString('target', true); + const enable = interaction.options.getBoolean('enable', true); + const updateData: any = {}; + + let labelKey = ''; + if (target === 'mimic') { + updateData.mimicEnabled = enable; + labelKey = 'commands.config.mimic.label'; + } else if (target === 'emoji') { + updateData.bigEmojiEnabled = enable; + labelKey = 'commands.config.emoji.label'; + } + + await prisma.guildConfig.upsert({ + where: { guildId: interaction.guildId }, + update: updateData, + create: { + guildId: interaction.guildId, + mimicEnabled: target === 'mimic' ? enable : false, + bigEmojiEnabled: target === 'emoji' ? enable : false, + }, }); + + const state = enable ? t(locale, 'commands.config.mimic.enabled') : t(locale, 'commands.config.mimic.disabled'); + const label = t(locale, labelKey); + + const embed = new EmbedBuilder() + .setColor(enable ? Colors.Green : Colors.Grey) + .setTitle(t(locale, 'commands.config.title')) + .setDescription(`${label}: **${state}**`); + + return interaction.reply({ embeds: [embed], ephemeral: true }); } - - const updateData: any = {}; - if (mimic !== null) updateData.mimicEnabled = mimic; - if (emoji !== null) updateData.bigEmojiEnabled = emoji; - - await prisma.guildConfig.upsert({ - where: { guildId: interaction.guildId }, - update: updateData, - create: { - guildId: interaction.guildId, - mimicEnabled: mimic ?? false, - bigEmojiEnabled: emoji ?? false, - }, - }); - - const results: string[] = []; - if (mimic !== null) { - const state = mimic ? t(locale, 'commands.config.mimic.enabled') : t(locale, 'commands.config.mimic.disabled'); - results.push(`${t(locale, 'commands.config.mimic.label')}: **${state}**`); - } - if (emoji !== null) { - const state = emoji ? t(locale, 'commands.config.emoji.enabled') : t(locale, 'commands.config.emoji.disabled'); - results.push(`${t(locale, 'commands.config.emoji.label')}: **${state}**`); - } - - const embed = new EmbedBuilder() - .setColor(0x00AE86) - .setTitle(t(locale, 'commands.config.title')) - .setDescription(results.join('\n')); - - await interaction.reply({ embeds: [embed], ephemeral: true }); }, }; + + + diff --git a/src/commands/language.ts b/src/commands/language.ts index f4c3ab4..476670c 100644 --- a/src/commands/language.ts +++ b/src/commands/language.ts @@ -1,26 +1,14 @@ -import { SlashCommandBuilder, ChatInputCommandInteraction, PermissionFlagsBits } from 'discord.js'; +import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js'; import { prisma } from '../database'; import { t, SupportedLocale, SUPPORTED_LOCALES } from '../i18n'; export default { data: new SlashCommandBuilder() .setName('language') - .setDescription('Set the language for the bot.') + .setDescription('Set your personal language for the bot.') .setDescriptionLocalizations({ - ko: '봇의 언어를 설정합니다.', + ko: '봇의 개인 사용 언어를 설정합니다.', }) - .addStringOption(option => - option.setName('scope') - .setDescription('Apply to yourself or the entire server') - .setDescriptionLocalizations({ - ko: '본인에게만 또는 서버 전체에 적용', - }) - .setRequired(true) - .addChoices( - { name: 'Just for me', name_localizations: { ko: '나만 적용' }, value: 'user' }, - { name: 'Entire server (Admin only)', name_localizations: { ko: '서버 전체 (관리자 전용)' }, value: 'server' }, - ) - ) .addStringOption(option => option.setName('locale') .setDescription('Language to use') @@ -35,7 +23,6 @@ export default { ), async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { - const scope = interaction.options.getString('scope', true) as 'user' | 'server'; const newLocale = interaction.options.getString('locale', true) as SupportedLocale; // Validate locale (safety check) @@ -44,39 +31,17 @@ export default { return; } - if (scope === 'user') { - await prisma.userLocale.upsert({ - where: { userId: interaction.user.id }, - update: { locale: newLocale }, - create: { userId: interaction.user.id, locale: newLocale }, - }); + await prisma.userLocale.upsert({ + where: { userId: interaction.user.id }, + update: { locale: newLocale }, + create: { userId: interaction.user.id, locale: newLocale }, + }); - // Respond in the NEWLY selected locale - await interaction.reply({ - content: t(newLocale, 'commands.language.userSet', { locale: newLocale === 'ko' ? '한국어' : 'English' }), - ephemeral: true, - }); - } else if (scope === 'server') { - // Require Administrator permission - if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { - await interaction.reply({ - content: t(locale, 'commands.language.serverPermissionDenied'), - ephemeral: true, - }); - return; - } - - await prisma.guildConfig.upsert({ - where: { guildId: interaction.guildId! }, - update: { locale: newLocale }, - create: { guildId: interaction.guildId!, locale: newLocale }, - }); - - // Respond in the NEWLY selected locale - await interaction.reply({ - content: t(newLocale, 'commands.language.serverSet', { locale: newLocale === 'ko' ? '한국어' : 'English' }), - ephemeral: true, - }); - } + // Respond in the NEWLY selected locale + await interaction.reply({ + content: t(newLocale, 'commands.language.userSet', { locale: newLocale === 'ko' ? '한국어' : 'English' }), + ephemeral: true, + }); }, }; + diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 8f47851..d676a51 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,7 +1,7 @@ -import { - SlashCommandBuilder, - PermissionFlagsBits, - ChatInputCommandInteraction, +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + PermissionFlagsBits } from 'discord.js'; import { SetupWizardRenderer } from '../services/SetupWizardRenderer'; import { SupportedLocale } from '../i18n'; @@ -9,16 +9,17 @@ import { SupportedLocale } from '../i18n'; export default { data: new SlashCommandBuilder() .setName('setup') - .setDescription('Run the setup wizard to configure the bot step by step.') + .setDescription('Initial setup wizard for the bot.') .setDescriptionLocalizations({ - ko: '설정 마법사를 실행하여 봇의 필수 기능들을 단계별로 설정합니다.', + 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. + if (!interaction.guildId) return; + const { embed, components } = await SetupWizardRenderer.renderStep(0, interaction, locale); - await interaction.reply({ + return interaction.reply({ embeds: [embed], components, ephemeral: true, diff --git a/src/commands/voice.ts b/src/commands/voice.ts index d5d4523..ba86aff 100644 --- a/src/commands/voice.ts +++ b/src/commands/voice.ts @@ -18,104 +18,78 @@ export default { ko: '임시 음성 채널 설정을 관리합니다.', }) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - // --- Setup Subcommands --- - .addSubcommandGroup(group => - group - .setName('setup') + // --- Generator Subcommand --- + .addSubcommand(subcommand => + subcommand + .setName('generator') .setDescription('Configure the voice generator channel.') .setDescriptionLocalizations({ ko: '음성 생성기 채널을 설정합니다.' }) - .addSubcommand(subcommand => - subcommand - .setName('set') - .setDescription('Set an existing voice channel as a Generator') - .setDescriptionLocalizations({ ko: '기존 음성 채널을 생성기로 설정합니다' }) - .addChannelOption(option => - option.setName('channel') - .setDescription('The voice channel to act as the Generator') - .setDescriptionLocalizations({ ko: '생성기로 사용할 음성 채널' }) - .setRequired(true) - .addChannelTypes(ChannelType.GuildVoice) - ) - .addChannelOption(option => - option.setName('category') - .setDescription('(Optional) The category where temp channels will be created') - .setDescriptionLocalizations({ ko: '(선택) 임시 채널이 생성될 카테고리' }) - .setRequired(false) - .addChannelTypes(ChannelType.GuildCategory) + .addStringOption(option => + option.setName('action') + .setDescription('Action to perform') + .setRequired(true) + .addChoices( + { name: 'set (Set Existing)', value: 'set' }, + { name: 'create (Create New)', value: 'create' }, ) ) - .addSubcommand(subcommand => - subcommand - .setName('create') - .setDescription('Create a new voice channel and set it as a Generator') - .setDescriptionLocalizations({ ko: '새 음성 채널을 만들고 생성기로 설정합니다' }) - .addStringOption(option => - option.setName('name') - .setDescription('The name of the new generator voice channel') - .setDescriptionLocalizations({ ko: '새 생성기 음성 채널의 이름' }) - .setRequired(true) - ) - .addChannelOption(option => - option.setName('category') - .setDescription('(Optional) The category where the new channel will be created') - .setDescriptionLocalizations({ ko: '(선택) 새 채널이 생성될 카테고리' }) - .setRequired(false) - .addChannelTypes(ChannelType.GuildCategory) - ) + .addChannelOption(option => + option.setName('channel') + .setDescription('Voice channel (for set action)') + .addChannelTypes(ChannelType.GuildVoice) + ) + .addStringOption(option => + option.setName('name') + .setDescription('New channel name (for create action)') + ) + .addChannelOption(option => + option.setName('category') + .setDescription('Target category for temp channels') + .addChannelTypes(ChannelType.GuildCategory) ) ) - // --- Config Subcommands --- - .addSubcommandGroup(group => - group - .setName('config') + // --- Settings Subcommand --- + .addSubcommand(subcommand => + subcommand + .setName('settings') .setDescription('Manage default settings for temporary channels.') .setDescriptionLocalizations({ ko: '임시 채널의 기본 설정을 관리합니다.' }) - .addSubcommand(subcommand => - subcommand - .setName('name') - .setDescription('Set the default naming template for new temp channels.') - .setDescriptionLocalizations({ ko: '임시 채널의 기본 이름 템플릿을 설정합니다.' }) - .addStringOption(option => - option.setName('template') - .setDescription('Template using {{username}} placeholder') - .setDescriptionLocalizations({ ko: '{{username}}을 포함한 이름 템플릿' }) - .setRequired(true) + .addStringOption(option => + option.setName('action') + .setDescription('Action to perform') + .setRequired(true) + .addChoices( + { name: 'name (Template)', value: 'name' }, + { name: 'limit (User Count)', value: 'limit' }, + { name: 'status (Check Config)', value: 'status' }, ) ) - .addSubcommand(subcommand => - subcommand - .setName('limit') - .setDescription('Set the default user limit for new temp channels.') - .setDescriptionLocalizations({ ko: '임시 채널의 기본 인원 제한을 설정합니다.' }) - .addIntegerOption(option => - option.setName('limit') - .setDescription('User limit (0-99, 0 = unlimited)') - .setDescriptionLocalizations({ ko: '인원 제한 (0-99, 0 = 무제한)' }) - .setRequired(true) - .setMinValue(0) - .setMaxValue(99) - ) + .addStringOption(option => + option.setName('template') + .setDescription('Naming template (for name action)') ) - .addSubcommand(subcommand => - subcommand - .setName('status') - .setDescription('View current guild voice settings.') - .setDescriptionLocalizations({ ko: '현재 서버의 음성 설정을 확인합니다.' }) + .addIntegerOption(option => + option.setName('limit') + .setDescription('User limit (for limit action)') + .setMinValue(0) + .setMaxValue(99) ) ), async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { - const group = interaction.options.getSubcommandGroup(); const subcommand = interaction.options.getSubcommand(); const guildId = interaction.guildId!; try { - // --- SETUP GROUP --- - if (group === 'setup') { + // --- GENERATOR --- + if (subcommand === 'generator') { + const action = interaction.options.getString('action', true); const category = interaction.options.getChannel('category'); - if (subcommand === 'set') { - const channel = interaction.options.getChannel('channel', true); + if (action === 'set') { + const channel = interaction.options.getChannel('channel'); + if (!channel) return interaction.reply({ content: '❌ `channel` 옵션을 선택해주세요.', ephemeral: true }); + await prisma.voiceGenerator.upsert({ where: { channelId: channel.id }, update: { categoryId: category?.id || null, guildId }, @@ -127,8 +101,10 @@ export default { }); } - if (subcommand === 'create') { - const name = interaction.options.getString('name', true); + if (action === 'create') { + const name = interaction.options.getString('name'); + if (!name) return interaction.reply({ content: '❌ `name` 옵션을 입력해주세요.', ephemeral: true }); + const newChannel = await interaction.guild!.channels.create({ name, type: ChannelType.GuildVoice, @@ -144,10 +120,14 @@ export default { } } - // --- CONFIG GROUP --- - if (group === 'config') { - if (subcommand === 'name') { - const template = interaction.options.getString('template', true); + // --- SETTINGS --- + if (subcommand === 'settings') { + const action = interaction.options.getString('action', true); + + if (action === 'name') { + const template = interaction.options.getString('template'); + if (!template) return interaction.reply({ content: '❌ `template` 옵션을 입력해주세요.', ephemeral: true }); + await prisma.voiceGuildConfig.upsert({ where: { guildId }, update: { defaultNameTemplate: template }, @@ -159,8 +139,10 @@ export default { }); } - if (subcommand === 'limit') { - const limit = interaction.options.getInteger('limit', true); + if (action === 'limit') { + const limit = interaction.options.getInteger('limit'); + if (limit === null) return interaction.reply({ content: '❌ `limit` 옵션을 입력해주세요.', ephemeral: true }); + await prisma.voiceGuildConfig.upsert({ where: { guildId }, update: { defaultUserLimit: limit }, @@ -172,7 +154,7 @@ export default { }); } - if (subcommand === 'status') { + if (action === 'status') { const config = await prisma.voiceGuildConfig.findUnique({ where: { guildId } }); const embed = new EmbedBuilder() .setTitle(t(locale, 'commands.voiceConfig.statusTitle')) @@ -201,3 +183,4 @@ export default { } }, }; +