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