refactor: flatten command structures by replacing subcommand groups with action-based string options
This commit is contained in:
parent
63c9930fd9
commit
4cc14d8153
|
|
@ -32,6 +32,7 @@ description: work routine
|
|||
- 동시 실행 시 충돌을 방지하기 위해, 테스트 구동 시에는 반드시 **임의의 고유한 `INSTANCE_ID` 환경 변수를 할당**(`INSTANCE_ID=agent-test-xxxx yarn run dev`)하여 기존 인스턴스와 락(Lock)이나 로그가 겹치지 않게 합니다.
|
||||
- 필요하다면 단위 테스트(`yarn test`)를 실행합니다.
|
||||
- 오류 발생 시 자체적으로 판단하고 디버깅하여 해결합니다.
|
||||
- 테스트가 완료되면 실행한 인스턴스를 종료합니다.
|
||||
|
||||
### 4단계: 사용자 최종 테스트 지원 (Final Manual Testing)
|
||||
|
||||
|
|
|
|||
|
|
@ -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시간 소요)할 때까지 대기하거나 봇을 재구동해야 함.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue