refactor: flatten command structures by replacing subcommand groups with action-based string options

This commit is contained in:
이정수 2026-03-30 10:27:11 +09:00
parent 63c9930fd9
commit 4cc14d8153
8 changed files with 379 additions and 295 deletions

View File

@ -32,6 +32,7 @@ description: work routine
- 동시 실행 시 충돌을 방지하기 위해, 테스트 구동 시에는 반드시 **임의의 고유한 `INSTANCE_ID` 환경 변수를 할당**(`INSTANCE_ID=agent-test-xxxx yarn run dev`)하여 기존 인스턴스와 락(Lock)이나 로그가 겹치지 않게 합니다.
- 필요하다면 단위 테스트(`yarn test`)를 실행합니다.
- 오류 발생 시 자체적으로 판단하고 디버깅하여 해결합니다.
- 테스트가 완료되면 실행한 인스턴스를 종료합니다.
### 4단계: 사용자 최종 테스트 지원 (Final Manual Testing)

View File

@ -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시간 소요)할 때까지 대기하거나 봇을 재구동해야 함.

View File

@ -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)

View File

@ -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<AuditStatus, number> = { 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<AuditStatus, number> = { 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 {
}
},
};

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import {
SlashCommandBuilder,
PermissionFlagsBits,
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,

View File

@ -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 {
}
},
};