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)이나 로그가 겹치지 않게 합니다. - 동시 실행 시 충돌을 방지하기 위해, 테스트 구동 시에는 반드시 **임의의 고유한 `INSTANCE_ID` 환경 변수를 할당**(`INSTANCE_ID=agent-test-xxxx yarn run dev`)하여 기존 인스턴스와 락(Lock)이나 로그가 겹치지 않게 합니다.
- 필요하다면 단위 테스트(`yarn test`)를 실행합니다. - 필요하다면 단위 테스트(`yarn test`)를 실행합니다.
- 오류 발생 시 자체적으로 판단하고 디버깅하여 해결합니다. - 오류 발생 시 자체적으로 판단하고 디버깅하여 해결합니다.
- 테스트가 완료되면 실행한 인스턴스를 종료합니다.
### 4단계: 사용자 최종 테스트 지원 (Final Manual Testing) ### 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: 권한 진단 기능 구현 (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: 에러 안내 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-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: '감사 로그 및 봇 권한을 관리합니다.', ko: '감사 로그 및 봇 권한을 관리합니다.',
}) })
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
// --- Channel Subcommand Group --- // --- Channel Subcommand ---
.addSubcommandGroup(group => .addSubcommand(subcommand =>
group subcommand
.setName('channel') .setName('channel')
.setDescription('Manage audit log channel settings.') .setDescription('Manage audit log channel settings.')
.setDescriptionLocalizations({ ko: '감사 채널 설정을 관리합니다.' }) .setDescriptionLocalizations({ ko: '감사 채널 설정을 관리합니다.' })
.addSubcommand(subcommand => .addStringOption(option =>
subcommand option.setName('action')
.setName('set') .setDescription('Action to perform')
.setDescription('Set the audit log channel.') .setRequired(true)
.setDescriptionLocalizations({ ko: '감사 채널을 설정합니다.' }) .addChoices(
.addChannelOption(option => { name: 'set (Set Channel)', value: 'set' },
option.setName('channel') { name: 'clear (Reset)', value: 'clear' },
.setDescription('The text channel to use for audit logs.') { name: 'status (Check)', value: 'status' },
.setDescriptionLocalizations({ ko: '감사 로그를 기록할 텍스트 채널' }) { name: 'filter (Toggle Categories)', value: 'filter' },
.addChannelTypes(ChannelType.GuildText)
.setRequired(true)
) )
) )
.addSubcommand(subcommand => .addChannelOption(option =>
subcommand option.setName('channel')
.setName('clear') .setDescription('Channel to use (for set action)')
.setDescription('Clear the audit log channel.') .addChannelTypes(ChannelType.GuildText)
.setDescriptionLocalizations({ ko: '감사 채널 설정을 해제합니다.' })
) )
.addSubcommand(subcommand => .addStringOption(option =>
subcommand option.setName('category')
.setName('status') .setDescription('Category to toggle (for filter action)')
.setDescription('Check current audit log channel status.') .addChoices(
.setDescriptionLocalizations({ ko: '현재 감사 채널 설정 상태를 확인합니다.' }) { 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 => .addBooleanOption(option =>
subcommand option.setName('enable')
.setName('filter') .setDescription('Enable or disable (for filter action)')
.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)
)
) )
) )
// --- Permissions Subcommand --- // --- Bot Subcommand ---
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('permissions') .setName('bot')
.setDescription('Check if the bot has all required permissions for its features.') .setDescription('Diagnose and manage bot-specific status.')
.setDescriptionLocalizations({ ko: '봇이 필요한 권한을 가지고 있는지 진단합니다.' }) .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) { async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
const group = interaction.options.getSubcommandGroup();
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
const guild = interaction.guild!; const guild = interaction.guild!;
try { try {
// --- CHANNEL GROUP --- // --- CHANNEL ---
if (group === 'channel') { if (subcommand === 'channel') {
if (subcommand === 'set') { 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 }); await interaction.deferReply({ ephemeral: true });
const channel = interaction.options.getChannel('channel', true) as TextChannel;
const botMember = guild.members.me; const botMember = guild.members.me;
if (!botMember) return; if (!botMember) return;
const perms = channel.permissionsFor(botMember); const perms = channel.permissionsFor(botMember);
@ -148,16 +140,16 @@ export default {
return; return;
} }
if (subcommand === 'clear') { if (action === 'clear') {
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ ephemeral: true });
await auditLogService.clearChannel(guild.id); await auditLogService.clearChannel(guild.id);
return interaction.editReply({ content: `✅ 감사 채널 설정이 해제되었습니다.` }); return interaction.editReply({ content: `✅ 감사 채널 설정이 해제되었습니다.` });
} }
if (subcommand === 'status') { if (action === 'status') {
await interaction.deferReply({ ephemeral: true }); await interaction.deferReply({ ephemeral: true });
const config = await auditLogService.getChannel(guild.id); 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 disabled = config.disabledCategories.length > 0 ? config.disabledCategories.map((c: string) => `\`${c}\``).join(', ') : '없음 (모두 수신 중)';
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
@ -171,62 +163,70 @@ export default {
return interaction.editReply({ embeds: [embed] }); return interaction.editReply({ embeds: [embed] });
} }
if (subcommand === 'filter') { if (action === 'filter') {
await interaction.deferReply({ ephemeral: true }); const category = interaction.options.getString('category') as AuditCategory;
const category = interaction.options.getString('category', true) as AuditCategory; const enable = interaction.options.getBoolean('enable');
const enable = interaction.options.getBoolean('enable', true);
if (!category || enable === null) {
return interaction.reply({ content: '❌ `category` 및 `enable` 옵션을 모두 입력해주세요.', ephemeral: true });
}
await interaction.deferReply({ ephemeral: true });
const config = await auditLogService.getChannel(guild.id); 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); await auditLogService.setFilter(guild.id, category, enable);
return interaction.editReply({ content: `✅ **${category}** 카테고리의 감사 로그 수신이 **${enable ? '활성화(ON)' : '비활성화(OFF)'}** 되었습니다.` }); return interaction.editReply({ content: `✅ **${category}** 카테고리의 감사 로그 수신이 **${enable ? '활성화(ON)' : '비활성화(OFF)'}** 되었습니다.` });
} }
} }
// --- PERMISSIONS SUBCOMMAND --- // --- BOT ---
if (subcommand === 'permissions') { if (subcommand === 'bot') {
await interaction.deferReply({ ephemeral: true }); const action = interaction.options.getString('action', true);
const results = await PermissionAuditService.auditGuild(guild);
if (results.length === 0) return interaction.editReply({ content: t(locale, 'commands.permissionAudit.noResults') });
const sorted = [...results].sort((a, b) => { if (action === 'permissions') {
const order: Record<AuditStatus, number> = { FAIL: 0, WARNING: 1, SUCCESS: 2 }; await interaction.deferReply({ ephemeral: true });
return order[a.status] - order[b.status]; 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 sorted = [...results].sort((a, b) => {
const failCount = results.filter((r) => r.status === 'FAIL').length; const order: Record<AuditStatus, number> = { FAIL: 0, WARNING: 1, SUCCESS: 2 };
const warnCount = results.filter((r) => r.status === 'WARNING').length; return order[a.status] - order[b.status];
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, { const lines = sorted.map((r) => buildResultLine(r, locale));
category: 'PERMISSION', const failCount = results.filter((r) => r.status === 'FAIL').length;
severity: failCount > 0 ? 'ERROR' : 'WARN', const warnCount = results.filter((r) => r.status === 'WARNING').length;
title: '권한 감사에서 문제 감지',
description: `\`/audit permissions\` 실행 중 권한 문제가 확인되었습니다.\n\n${descLines.join('\n')}` let summary = failCount === 0 && warnCount === 0 ? t(locale, 'commands.permissionAudit.summaryOk') : t(locale, 'commands.permissionAudit.summaryIssue', { fail: String(failCount), warn: String(warnCount) });
}).catch(() => {});
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) { } catch (error) {
console.error('Error in audit command', 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 { prisma } from '../database';
import { t, resolveLocale } from '../i18n'; import { t, SupportedLocale, SUPPORTED_LOCALES } from '../i18n';
export default { export default {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('config') .setName('config')
.setDescription('Configure bot features') .setDescription('Configure bot features and server settings.')
.setDescriptionLocalizations({
ko: '봇의 기능 및 서버 설정을 관리합니다.',
})
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addBooleanOption(opt => opt.setName('mimic').setDescription('Enable or disable Mimic feature')) // --- System Subcommand ---
.addBooleanOption(opt => opt.setName('emoji').setDescription('Enable or disable Big Emoji feature')), .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; if (!interaction.guildId) return;
const subcommand = interaction.options.getSubcommand();
const mimic = interaction.options.getBoolean('mimic'); // --- SYSTEM ---
const emoji = interaction.options.getBoolean('emoji'); if (subcommand === 'system') {
const action = interaction.options.getString('action', true);
// Resolve proper supported locale // Removed 'setup' action (now in standalone /setup command)
const locale = resolveLocale({
discordLocale: interaction.locale,
guildLocale: interaction.guildLocale?.toString(),
});
if (mimic === null && emoji === null) { if (action === 'language') {
return interaction.reply({ const newLocale = interaction.options.getString('locale') as SupportedLocale;
content: t(locale, 'commands.config.noOptions'), if (!newLocale) {
ephemeral: true 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 { prisma } from '../database';
import { t, SupportedLocale, SUPPORTED_LOCALES } from '../i18n'; import { t, SupportedLocale, SUPPORTED_LOCALES } from '../i18n';
export default { export default {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('language') .setName('language')
.setDescription('Set the language for the bot.') .setDescription('Set your personal language for the bot.')
.setDescriptionLocalizations({ .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 => .addStringOption(option =>
option.setName('locale') option.setName('locale')
.setDescription('Language to use') .setDescription('Language to use')
@ -35,7 +23,6 @@ export default {
), ),
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { 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; const newLocale = interaction.options.getString('locale', true) as SupportedLocale;
// Validate locale (safety check) // Validate locale (safety check)
@ -44,39 +31,17 @@ export default {
return; return;
} }
if (scope === 'user') { await prisma.userLocale.upsert({
await prisma.userLocale.upsert({ where: { userId: interaction.user.id },
where: { userId: interaction.user.id }, update: { locale: newLocale },
update: { locale: newLocale }, create: { userId: interaction.user.id, locale: newLocale },
create: { userId: interaction.user.id, locale: newLocale }, });
});
// Respond in the NEWLY selected locale // Respond in the NEWLY selected locale
await interaction.reply({ await interaction.reply({
content: t(newLocale, 'commands.language.userSet', { locale: newLocale === 'ko' ? '한국어' : 'English' }), content: t(newLocale, 'commands.language.userSet', { locale: newLocale === 'ko' ? '한국어' : 'English' }),
ephemeral: true, 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,
});
}
}, },
}; };

View File

@ -1,7 +1,7 @@
import { import {
SlashCommandBuilder, SlashCommandBuilder,
PermissionFlagsBits, ChatInputCommandInteraction,
ChatInputCommandInteraction, PermissionFlagsBits
} from 'discord.js'; } from 'discord.js';
import { SetupWizardRenderer } from '../services/SetupWizardRenderer'; import { SetupWizardRenderer } from '../services/SetupWizardRenderer';
import { SupportedLocale } from '../i18n'; import { SupportedLocale } from '../i18n';
@ -9,16 +9,17 @@ import { SupportedLocale } from '../i18n';
export default { export default {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('setup') .setName('setup')
.setDescription('Run the setup wizard to configure the bot step by step.') .setDescription('Initial setup wizard for the bot.')
.setDescriptionLocalizations({ .setDescriptionLocalizations({
ko: '설정 마법사를 실행하여 봇의 필수 기능들을 단계별로 설정합니다.', ko: '봇의 초기 환경 설정을 위한 마법사를 실행합니다.',
}) })
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator), .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { 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); const { embed, components } = await SetupWizardRenderer.renderStep(0, interaction, locale);
await interaction.reply({ return interaction.reply({
embeds: [embed], embeds: [embed],
components, components,
ephemeral: true, ephemeral: true,

View File

@ -18,104 +18,78 @@ export default {
ko: '임시 음성 채널 설정을 관리합니다.', ko: '임시 음성 채널 설정을 관리합니다.',
}) })
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
// --- Setup Subcommands --- // --- Generator Subcommand ---
.addSubcommandGroup(group => .addSubcommand(subcommand =>
group subcommand
.setName('setup') .setName('generator')
.setDescription('Configure the voice generator channel.') .setDescription('Configure the voice generator channel.')
.setDescriptionLocalizations({ ko: '음성 생성기 채널을 설정합니다.' }) .setDescriptionLocalizations({ ko: '음성 생성기 채널을 설정합니다.' })
.addSubcommand(subcommand => .addStringOption(option =>
subcommand option.setName('action')
.setName('set') .setDescription('Action to perform')
.setDescription('Set an existing voice channel as a Generator') .setRequired(true)
.setDescriptionLocalizations({ ko: '기존 음성 채널을 생성기로 설정합니다' }) .addChoices(
.addChannelOption(option => { name: 'set (Set Existing)', value: 'set' },
option.setName('channel') { name: 'create (Create New)', value: 'create' },
.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)
) )
) )
.addSubcommand(subcommand => .addChannelOption(option =>
subcommand option.setName('channel')
.setName('create') .setDescription('Voice channel (for set action)')
.setDescription('Create a new voice channel and set it as a Generator') .addChannelTypes(ChannelType.GuildVoice)
.setDescriptionLocalizations({ ko: '새 음성 채널을 만들고 생성기로 설정합니다' }) )
.addStringOption(option => .addStringOption(option =>
option.setName('name') option.setName('name')
.setDescription('The name of the new generator voice channel') .setDescription('New channel name (for create action)')
.setDescriptionLocalizations({ ko: '새 생성기 음성 채널의 이름' }) )
.setRequired(true) .addChannelOption(option =>
) option.setName('category')
.addChannelOption(option => .setDescription('Target category for temp channels')
option.setName('category') .addChannelTypes(ChannelType.GuildCategory)
.setDescription('(Optional) The category where the new channel will be created')
.setDescriptionLocalizations({ ko: '(선택) 새 채널이 생성될 카테고리' })
.setRequired(false)
.addChannelTypes(ChannelType.GuildCategory)
)
) )
) )
// --- Config Subcommands --- // --- Settings Subcommand ---
.addSubcommandGroup(group => .addSubcommand(subcommand =>
group subcommand
.setName('config') .setName('settings')
.setDescription('Manage default settings for temporary channels.') .setDescription('Manage default settings for temporary channels.')
.setDescriptionLocalizations({ ko: '임시 채널의 기본 설정을 관리합니다.' }) .setDescriptionLocalizations({ ko: '임시 채널의 기본 설정을 관리합니다.' })
.addSubcommand(subcommand => .addStringOption(option =>
subcommand option.setName('action')
.setName('name') .setDescription('Action to perform')
.setDescription('Set the default naming template for new temp channels.') .setRequired(true)
.setDescriptionLocalizations({ ko: '임시 채널의 기본 이름 템플릿을 설정합니다.' }) .addChoices(
.addStringOption(option => { name: 'name (Template)', value: 'name' },
option.setName('template') { name: 'limit (User Count)', value: 'limit' },
.setDescription('Template using {{username}} placeholder') { name: 'status (Check Config)', value: 'status' },
.setDescriptionLocalizations({ ko: '{{username}}을 포함한 이름 템플릿' })
.setRequired(true)
) )
) )
.addSubcommand(subcommand => .addStringOption(option =>
subcommand option.setName('template')
.setName('limit') .setDescription('Naming template (for name action)')
.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)
)
) )
.addSubcommand(subcommand => .addIntegerOption(option =>
subcommand option.setName('limit')
.setName('status') .setDescription('User limit (for limit action)')
.setDescription('View current guild voice settings.') .setMinValue(0)
.setDescriptionLocalizations({ ko: '현재 서버의 음성 설정을 확인합니다.' }) .setMaxValue(99)
) )
), ),
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
const group = interaction.options.getSubcommandGroup();
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId!; const guildId = interaction.guildId!;
try { try {
// --- SETUP GROUP --- // --- GENERATOR ---
if (group === 'setup') { if (subcommand === 'generator') {
const action = interaction.options.getString('action', true);
const category = interaction.options.getChannel('category'); const category = interaction.options.getChannel('category');
if (subcommand === 'set') { if (action === 'set') {
const channel = interaction.options.getChannel('channel', true); const channel = interaction.options.getChannel('channel');
if (!channel) return interaction.reply({ content: '❌ `channel` 옵션을 선택해주세요.', ephemeral: true });
await prisma.voiceGenerator.upsert({ await prisma.voiceGenerator.upsert({
where: { channelId: channel.id }, where: { channelId: channel.id },
update: { categoryId: category?.id || null, guildId }, update: { categoryId: category?.id || null, guildId },
@ -127,8 +101,10 @@ export default {
}); });
} }
if (subcommand === 'create') { if (action === 'create') {
const name = interaction.options.getString('name', true); const name = interaction.options.getString('name');
if (!name) return interaction.reply({ content: '❌ `name` 옵션을 입력해주세요.', ephemeral: true });
const newChannel = await interaction.guild!.channels.create({ const newChannel = await interaction.guild!.channels.create({
name, name,
type: ChannelType.GuildVoice, type: ChannelType.GuildVoice,
@ -144,10 +120,14 @@ export default {
} }
} }
// --- CONFIG GROUP --- // --- SETTINGS ---
if (group === 'config') { if (subcommand === 'settings') {
if (subcommand === 'name') { const action = interaction.options.getString('action', true);
const template = interaction.options.getString('template', true);
if (action === 'name') {
const template = interaction.options.getString('template');
if (!template) return interaction.reply({ content: '❌ `template` 옵션을 입력해주세요.', ephemeral: true });
await prisma.voiceGuildConfig.upsert({ await prisma.voiceGuildConfig.upsert({
where: { guildId }, where: { guildId },
update: { defaultNameTemplate: template }, update: { defaultNameTemplate: template },
@ -159,8 +139,10 @@ export default {
}); });
} }
if (subcommand === 'limit') { if (action === 'limit') {
const limit = interaction.options.getInteger('limit', true); const limit = interaction.options.getInteger('limit');
if (limit === null) return interaction.reply({ content: '❌ `limit` 옵션을 입력해주세요.', ephemeral: true });
await prisma.voiceGuildConfig.upsert({ await prisma.voiceGuildConfig.upsert({
where: { guildId }, where: { guildId },
update: { defaultUserLimit: limit }, update: { defaultUserLimit: limit },
@ -172,7 +154,7 @@ export default {
}); });
} }
if (subcommand === 'status') { if (action === 'status') {
const config = await prisma.voiceGuildConfig.findUnique({ where: { guildId } }); const config = await prisma.voiceGuildConfig.findUnique({ where: { guildId } });
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(t(locale, 'commands.voiceConfig.statusTitle')) .setTitle(t(locale, 'commands.voiceConfig.statusTitle'))
@ -201,3 +183,4 @@ export default {
} }
}, },
}; };