feat: Add an audit log category selection step to the setup wizard, shifting subsequent steps and updating related logic and translations.

This commit is contained in:
이정수 2026-03-27 16:56:37 +09:00
parent bdd91f6737
commit b81bc6b146
6 changed files with 132 additions and 28 deletions

View File

@ -35,26 +35,34 @@
- **컴포넌트**: 점검 통과 상태 (✅ 모두 정상 / ⚠️ 일부 부족). 부족한 경우 권한 부여를 안내합니다. (권한 검사 모듈 `/audit-permissions`의 축소판) - **컴포넌트**: 점검 통과 상태 (✅ 모두 정상 / ⚠️ 일부 부족). 부족한 경우 권한 부여를 안내합니다. (권한 검사 모듈 `/audit-permissions`의 축소판)
- **액션**: `[다시 검사]` / `[다음]` 버튼. - **액션**: `[다시 검사]` / `[다음]` 버튼.
### Step 3: 감사 채널 설정 (Audit Channel) ### Step 3: 감사 로그 채널 설정 (Audit Channel)
- **내용**: 봇의 주요 이벤트와 에러 로그를 남길 시스템 통보 채널을 지정합니다. - **내용**: 봇의 주요 이벤트와 에러 로그를 남길 시스템 통보 채널을 지정합니다.
- **컴포넌트**: - **컴포넌트**:
- 텍스트 채널을 선택하는 `ChannelSelectMenu` (ChannelType.GuildText 채널만). - 텍스트 채널을 선택하는 `ChannelSelectMenu` (ChannelType.GuildText 채널만).
- `[사용 안함(비활성화)]` 버튼. - `[사용 안함(비활성화)]` 버튼.
- **액션**: 채널 선택 시 DB 갱신 후, `[다음]` 버튼을 눌러 이동 (혹은 선택 즉시 자동 다음 이동 고려). - **액션**: 채널 선택 시 DB 갱신 후, Step 4로 이동 (사용 안함 선택 시 Step 5로 이동).
### Step 4: 임시 음성 채널 설정 (Voice Generator) ### Step 4: 감사 로그 카테고리 설정 (Audit Categories)
- **내용**: 수신할 감사 로그의 종류(음성, 권한, 시스템 등)를 필터링합니다.
- **컴포넌트**:
- 각 카테고리(SYSTEM, VOICE, PERMISSION, INVITE, MIMIC)를 토글할 수 있는 버튼 5개.
- 활성화 상태는 **초록색(Success)**, 비활성화는 **빨간색(Danger)**으로 표시.
- **액션**: 버튼 클릭 시 DB의 `disabledCategories` 필드 업데이트 후 현재 뷰 갱신. `[다음 단계]` 버튼으로 이동.
### Step 5: 임시 음성 채널 설정 (Voice Generator)
- **내용**: 임시 음성 채널 생성 시스템의 진입점이 될 '생성기 채널'을 지정하거나 새로 생성합니다. - **내용**: 임시 음성 채널 생성 시스템의 진입점이 될 '생성기 채널'을 지정하거나 새로 생성합니다.
- **컴포넌트**: - **컴포넌트**:
- 기존 생성할 채널을 고르는 `ChannelSelectMenu` (ChannelType.GuildVoice). - 기존 생성할 채널을 고르는 `ChannelSelectMenu` (ChannelType.GuildVoice).
- `[자동 생성]` 버튼: 봇이 새 음성 카테고리와 " 음성 채널 생성" 채널을 자동으로 만들어 줌. - `[자동 생성]` 버튼: 봇이 새 음성 카테고리와 " 음성 채널 생성" 채널을 자동으로 만들어 줌.
- `[건너뛰기]` 버튼. - `[건너뛰기]` 버튼.
- **액션**: 설정 시 즉각 시스템 구동. 이후 `[다음(완료)]` 클릭. - **액션**: 설정 시 즉각 시스템 구동. 이후 Step 6(완료 요약)으로 이동.
### Step 5: 설정 요약 (Summary) ### Step 6: 설정 요약 (Summary)
- **내용**: 지금까지 설정된 모든 항목의 최종 상태를 요약하여 보여줍니다. - **내용**: 지금까지 설정된 모든 항목(내용, 감사 채널/카테고리, 음성 채널)의 최종 상태를 요약하여 보여줍니다.
- **컴포넌트**: 설정 결과 요약 Embed. - **컴포넌트**: 설정 결과 요약 Embed.
- **액션**: `[설정 마치기]` 버튼 (누르면 "설정이 완료되었습니다"로 메시지 변경 후 버튼 비활성화). - **액션**: `[설정 마치기]` 버튼 (누르면 "설정이 완료되었습니다"로 메시지 변경 후 버튼 비활성화).

View File

@ -191,7 +191,12 @@ export const en: TranslationSchema = {
disableBtn: 'Disable Audit Logs', disableBtn: 'Disable Audit Logs',
nextBtn: 'Next Step' nextBtn: 'Next Step'
}, },
step4: { step4: {
title: '3-1⃣ Audit Log Categories',
desc: 'Select which log categories to receive. **Green** buttons are enabled, **Red** buttons are disabled.',
nextBtn: 'Next Step',
},
step5: {
title: '4⃣ Temporary Voice Channel Setup', title: '4⃣ Temporary Voice Channel Setup',
desc: 'Select the "Generator Channel" for temporary voice channels.\nYou can choose an existing channel or have the bot **auto-create** a new category and channel.', desc: 'Select the "Generator Channel" for temporary voice channels.\nYou can choose an existing channel or have the bot **auto-create** a new category and channel.',
placeholder: 'Select Generator Channel', placeholder: 'Select Generator Channel',
@ -199,15 +204,22 @@ export const en: TranslationSchema = {
skipBtn: 'Disable Temp Voice', skipBtn: 'Disable Temp Voice',
nextBtn: 'Finish Setup' nextBtn: 'Finish Setup'
}, },
step5: { step6: {
title: '🎉 Setup Summary', title: '🎉 Setup Summary',
desc: '**1. Language**: {{lang}}\n**2. Audit Channel**: {{audit}}\n**3. Temp Voice**: {{voice}}', desc: '**1. Language**: {{lang}}\n**2. Audit Channel**: {{audit}}\n**3. Audit Categories**: {{categories}}\n**4. Temp Voice**: {{voice}}',
finishBtn: 'Done' finishBtn: 'Done'
}, },
finished: '✅ The setup wizard has been finished.', finished: '✅ The setup wizard has been finished.',
expired: '⏳ The session has expired. Please run `/setup` again.', expired: '⏳ The session has expired. Please run `/setup` again.',
defaultCategoryName: 'Voice Channels', defaultCategoryName: 'Voice Channels',
defaultGeneratorName: ' Create Channel', defaultGeneratorName: ' Create Channel',
auditCategories: {
SYSTEM: 'System',
VOICE: 'Voice',
PERMISSION: 'Permission',
INVITE: 'Invite',
MIMIC: 'Mimic',
},
}, },
}, },

View File

@ -191,7 +191,12 @@ export const ko: TranslationSchema = {
disableBtn: '감사 채널 끄기/해제', disableBtn: '감사 채널 끄기/해제',
nextBtn: '다음 단계' nextBtn: '다음 단계'
}, },
step4: { step4: {
title: '3-1⃣ 감사 로그 카테고리',
desc: '수신할 로그 카테고리를 선택하세요. 버튼 색상이 **초록색**이면 수신, **빨간색**이면 차단 상태입니다.',
nextBtn: '다음 단계',
},
step5: {
title: '4⃣ 임시 음성 채널 설정', title: '4⃣ 임시 음성 채널 설정',
desc: '임시 음성 채널을 생성할 "생성기 채널"을 선택해주세요.\n기존의 채널을 고르거나 카테고리/채널을 봇이 **자동 생성**하게 할 수도 있습니다.', desc: '임시 음성 채널을 생성할 "생성기 채널"을 선택해주세요.\n기존의 채널을 고르거나 카테고리/채널을 봇이 **자동 생성**하게 할 수도 있습니다.',
placeholder: '생성기로 쓸 음성 채널 선택', placeholder: '생성기로 쓸 음성 채널 선택',
@ -199,15 +204,22 @@ export const ko: TranslationSchema = {
skipBtn: '임시 음성 사용 안함', skipBtn: '임시 음성 사용 안함',
nextBtn: '설정 완료' nextBtn: '설정 완료'
}, },
step5: { step6: {
title: '🎉 설정 완료 요약', title: '🎉 설정 완료 요약',
desc: '**1. 언어**: {{lang}}\n**2. 감사 채널**: {{audit}}\n**3. 임시 음성 채널**: {{voice}}', desc: '**1. 언어**: {{lang}}\n**2. 감사 채널**: {{audit}}\n**3. 감사 카테고리**: {{categories}}\n**4. 임시 음성 채널**: {{voice}}',
finishBtn: '마치기' finishBtn: '마치기'
}, },
finished: '✅ 설정 마법사를 종료했습니다.', finished: '✅ 설정 마법사를 종료했습니다.',
expired: '⏳ 시간이 만료되었습니다. `/setup`을 다시 실행해주세요.', expired: '⏳ 시간이 만료되었습니다. `/setup`을 다시 실행해주세요.',
defaultCategoryName: '음성 채널', defaultCategoryName: '음성 채널',
defaultGeneratorName: ' 채널 생성하기', defaultGeneratorName: ' 채널 생성하기',
auditCategories: {
SYSTEM: '시스템',
VOICE: '음성',
PERMISSION: '권한',
INVITE: '초대',
MIMIC: '흉내',
},
}, },
}, },

View File

@ -123,12 +123,20 @@ export interface TranslationSchema {
step1: { title: string; desc: string; placeholder: string; nextBtn: string; skipBtn: string; }; step1: { title: string; desc: string; placeholder: string; nextBtn: string; skipBtn: string; };
step2: { title: string; descOk: string; descFail: string; recheckBtn: string; nextBtn: string; }; step2: { title: string; descOk: string; descFail: string; recheckBtn: string; nextBtn: string; };
step3: { title: string; desc: string; placeholder: string; disableBtn: string; nextBtn: string; }; step3: { title: string; desc: string; placeholder: string; disableBtn: string; nextBtn: string; };
step4: { title: string; desc: string; placeholder: string; autoBtn: string; skipBtn: string; nextBtn: string; }; step4: { title: string; desc: string; nextBtn: string; };
step5: { title: string; desc: string; finishBtn: string; }; step5: { title: string; desc: string; placeholder: string; autoBtn: string; skipBtn: string; nextBtn: string; };
step6: { title: string; desc: string; finishBtn: string; };
finished: string; finished: string;
expired: string; expired: string;
defaultCategoryName: string; defaultCategoryName: string;
defaultGeneratorName: string; defaultGeneratorName: string;
auditCategories: {
SYSTEM: string;
VOICE: string;
PERMISSION: string;
INVITE: string;
MIMIC: string;
};
}; };
}; };

View File

@ -33,6 +33,29 @@ export async function handleSetupWizardInteraction(interaction: MessageComponent
return; return;
} }
// Step 4 Toggle: Audit Category
if (customId.startsWith('setup_audit_toggle_')) {
const category = customId.replace('setup_audit_toggle_', '');
const audit = await prisma.auditChannel.findUnique({ where: { guildId: interaction.guildId! } });
if (!audit) return;
let disabled = [...audit.disabledCategories];
if (disabled.includes(category)) {
disabled = disabled.filter(c => c !== category);
} else {
disabled.push(category);
}
await prisma.auditChannel.update({
where: { guildId: interaction.guildId! },
data: { disabledCategories: disabled }
});
const { embed, components } = await SetupWizardRenderer.renderStep(4, interaction, locale);
await interaction.update({ embeds: [embed], components });
return;
}
// Step 1: Language Select // Step 1: Language Select
if (customId === 'setup_lang_select' && interaction.isStringSelectMenu()) { if (customId === 'setup_lang_select' && interaction.isStringSelectMenu()) {
const selectedLocale = interaction.values[0] as SupportedLocale; const selectedLocale = interaction.values[0] as SupportedLocale;
@ -57,14 +80,15 @@ export async function handleSetupWizardInteraction(interaction: MessageComponent
create: { guildId: interaction.guildId!, channelId } create: { guildId: interaction.guildId!, channelId }
}); });
// Auto proceed to next step // Auto proceed to next step (Step 4: Categories)
const { embed, components } = await SetupWizardRenderer.renderStep(4, interaction, locale); const { embed, components } = await SetupWizardRenderer.renderStep(4, interaction, locale);
await interaction.update({ embeds: [embed], components }); await interaction.update({ embeds: [embed], components });
return; return;
} }
if (customId === 'setup_audit_disable') { if (customId === 'setup_audit_disable') {
await prisma.auditChannel.delete({ where: { guildId: interaction.guildId! } }).catch(() => {}); await prisma.auditChannel.delete({ where: { guildId: interaction.guildId! } }).catch(() => {});
const { embed, components } = await SetupWizardRenderer.renderStep(4, interaction, locale); // Skip categories if disabled, go to Step 5: Voice Setup
const { embed, components } = await SetupWizardRenderer.renderStep(5, interaction, locale);
await interaction.update({ embeds: [embed], components }); await interaction.update({ embeds: [embed], components });
return; return;
} }
@ -89,7 +113,7 @@ export async function handleSetupWizardInteraction(interaction: MessageComponent
create: { channelId: channel.id, guildId: interaction.guildId!, categoryId: channel.parentId } create: { channelId: channel.id, guildId: interaction.guildId!, categoryId: channel.parentId }
}); });
const { embed, components } = await SetupWizardRenderer.renderStep(5, interaction, locale); const { embed, components } = await SetupWizardRenderer.renderStep(6, interaction, locale);
await interaction.update({ embeds: [embed], components }); await interaction.update({ embeds: [embed], components });
return; return;
} }
@ -123,7 +147,7 @@ export async function handleSetupWizardInteraction(interaction: MessageComponent
} }
}); });
const { embed, components } = await SetupWizardRenderer.renderStep(5, interaction, locale); const { embed, components } = await SetupWizardRenderer.renderStep(6, interaction, locale);
await interaction.editReply({ embeds: [embed], components }); await interaction.editReply({ embeds: [embed], components });
} catch (e) { } catch (e) {
if ((e as Error).message.includes('Missing Permissions')) { if ((e as Error).message.includes('Missing Permissions')) {
@ -136,7 +160,7 @@ export async function handleSetupWizardInteraction(interaction: MessageComponent
if (customId === 'setup_voice_disable') { if (customId === 'setup_voice_disable') {
await prisma.voiceGenerator.deleteMany({ where: { guildId: interaction.guildId! } }); await prisma.voiceGenerator.deleteMany({ where: { guildId: interaction.guildId! } });
const { embed, components } = await SetupWizardRenderer.renderStep(5, interaction, locale); const { embed, components } = await SetupWizardRenderer.renderStep(6, interaction, locale);
await interaction.update({ embeds: [embed], components }); await interaction.update({ embeds: [embed], components });
return; return;
} }

View File

@ -124,26 +124,57 @@ export class SetupWizardRenderer {
} }
case 4: { case 4: {
const audit = await prisma.auditChannel.findUnique({ where: { guildId: interaction.guildId! } });
const disabled = audit?.disabledCategories || [];
embed.setTitle(t(locale, 'commands.setup.step4.title')) embed.setTitle(t(locale, 'commands.setup.step4.title'))
.setDescription(t(locale, 'commands.setup.step4.desc')); .setDescription(t(locale, 'commands.setup.step4.desc'));
const categories: ('SYSTEM' | 'VOICE' | 'PERMISSION' | 'INVITE' | 'MIMIC')[] = ['SYSTEM', 'VOICE', 'PERMISSION', 'INVITE', 'MIMIC'];
const row1 = new ActionRowBuilder<ButtonBuilder>();
categories.forEach(cat => {
const isEnabled = !disabled.includes(cat);
row1.addComponents(
new ButtonBuilder()
.setCustomId(`setup_audit_toggle_${cat}`)
.setLabel(t(locale, `commands.setup.auditCategories.${cat}`))
.setStyle(isEnabled ? ButtonStyle.Success : ButtonStyle.Danger)
);
});
const row2 = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('setup_next_5')
.setLabel(t(locale, 'commands.setup.step4.nextBtn'))
.setStyle(ButtonStyle.Primary)
);
components.push(row1, row2);
break;
}
case 5: {
embed.setTitle(t(locale, 'commands.setup.step5.title'))
.setDescription(t(locale, 'commands.setup.step5.desc'));
const select = new ChannelSelectMenuBuilder() const select = new ChannelSelectMenuBuilder()
.setCustomId('setup_voice_select') .setCustomId('setup_voice_select')
.setPlaceholder(t(locale, 'commands.setup.step4.placeholder')) .setPlaceholder(t(locale, 'commands.setup.step5.placeholder'))
.setChannelTypes(ChannelType.GuildVoice); .setChannelTypes(ChannelType.GuildVoice);
const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents( const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder() new ButtonBuilder()
.setCustomId('setup_voice_auto') .setCustomId('setup_voice_auto')
.setLabel(t(locale, 'commands.setup.step4.autoBtn')) .setLabel(t(locale, 'commands.setup.step5.autoBtn'))
.setStyle(ButtonStyle.Success), .setStyle(ButtonStyle.Success),
new ButtonBuilder() new ButtonBuilder()
.setCustomId('setup_voice_disable') .setCustomId('setup_voice_disable')
.setLabel(t(locale, 'commands.setup.step4.skipBtn')) .setLabel(t(locale, 'commands.setup.step5.skipBtn'))
.setStyle(ButtonStyle.Danger), .setStyle(ButtonStyle.Danger),
new ButtonBuilder() new ButtonBuilder()
.setCustomId('setup_next_5') .setCustomId('setup_next_6')
.setLabel(t(locale, 'commands.setup.step4.nextBtn')) .setLabel(t(locale, 'commands.setup.step5.nextBtn'))
.setStyle(ButtonStyle.Secondary) .setStyle(ButtonStyle.Secondary)
); );
@ -152,30 +183,39 @@ export class SetupWizardRenderer {
break; break;
} }
case 5: { case 6: {
if (!interaction.guild) throw new Error('Guild not found'); if (!interaction.guild) throw new Error('Guild not found');
const config = await prisma.guildConfig.findUnique({ where: { guildId: interaction.guild.id } }); const config = await prisma.guildConfig.findUnique({ where: { guildId: interaction.guild.id } });
const audit = await prisma.auditChannel.findUnique({ where: { guildId: interaction.guild.id } }); const audit = await prisma.auditChannel.findUnique({ where: { guildId: interaction.guild.id } });
const voice = await prisma.voiceGenerator.findFirst({ where: { guildId: interaction.guild.id } }); const voice = await prisma.voiceGenerator.findFirst({ where: { guildId: interaction.guild.id } });
embed.setTitle(t(locale, 'commands.setup.step5.title')) embed.setTitle(t(locale, 'commands.setup.step6.title'))
.setColor(Colors.Green); .setColor(Colors.Green);
const langStr = config?.locale === 'ko' ? 'Korean' : 'English'; const langStr = config?.locale === 'ko' ? 'Korean' : 'English';
const auditStr = audit?.channelId ? `<#${audit.channelId}>` : 'Disabled'; const auditStr = audit?.channelId ? `<#${audit.channelId}>` : 'Disabled';
const voiceStr = voice?.channelId ? `<#${voice.channelId}>` : 'Disabled'; const voiceStr = voice?.channelId ? `<#${voice.channelId}>` : 'Disabled';
embed.setDescription(t(locale, 'commands.setup.step5.desc', { // 감사 로그 카테고리 요약
let catStr = 'None';
if (audit?.channelId) {
const allCats: ('SYSTEM' | 'VOICE' | 'PERMISSION' | 'INVITE' | 'MIMIC')[] = ['SYSTEM', 'VOICE', 'PERMISSION', 'INVITE', 'MIMIC'];
const enabled = allCats.filter(c => !audit.disabledCategories.includes(c));
catStr = enabled.map(c => t(locale, `commands.setup.auditCategories.${c}`)).join(', ');
}
embed.setDescription(t(locale, 'commands.setup.step6.desc', {
lang: langStr, lang: langStr,
audit: auditStr, audit: auditStr,
categories: catStr,
voice: voiceStr voice: voiceStr
})); }));
const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents( const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder() new ButtonBuilder()
.setCustomId('setup_finish') .setCustomId('setup_finish')
.setLabel(t(locale, 'commands.setup.step5.finishBtn')) .setLabel(t(locale, 'commands.setup.step6.finishBtn'))
.setStyle(ButtonStyle.Success) .setStyle(ButtonStyle.Success)
); );