diff --git a/Docs/Plans/Temp_Voice_Channel_Plan.md b/Docs/Plans/Temp_Voice_Channel_Plan.md index e85e506..3443733 100644 --- a/Docs/Plans/Temp_Voice_Channel_Plan.md +++ b/Docs/Plans/Temp_Voice_Channel_Plan.md @@ -1,7 +1,7 @@ # Kord - 임시 음성 채널 기능 기획 (Temporary Voice Channels) ## 체인지로그 (Changelog) -- **2026-03-27**: 최초 기획서 작성 및 확정 +- **2026-03-27**: 최초 기획서 작성 및 확정 / UI 드롭다운(Select Menu) 형태로 개편 반영 ## 본문 (Body) @@ -16,7 +16,7 @@ (자세한 티어 정책은 **`Docs/Decisions/subscription_tiers.md`** 참고) ### 3. 임시 채널 관리 인터랙션 (UI) -임시 채널이 생성되면, 해당 음성 채널의 '텍스트 채팅(Voice Text Channel)' 영역에 **방장 전용 관리 인터랙션(버튼, Select Menu)** 이 전송됩니다. +임시 채널이 생성되면, 해당 음성 채널의 '텍스트 채팅(Voice Text Channel)' 영역에 **방장 전용 관리 인터랙션(드롭다운 형태의 Select Menu)** 이 전송됩니다. #### 기본 기능 (Essential) 1. **채널 명 변경**: 임시 채널의 이름 변경. (DB에 저장되어 추후 재생성 시에도 해당 이름으로 복구) @@ -31,11 +31,11 @@ 1. **인원수 제한 (User Limit)**: 슬라이더나 모달을 통해 채널 최대 인원 설정 (1~99명). 2. **잠금/해제 (Lock/Unlock)**: 외부인의 접속을 완전히 차단하고, 방장이 초대한 사람만 들어올 수 있게 전환. 3. **주인 위임 (Transfer Ownership)**: 방장이 나가더라도 채널이 유지되는 조건(`deleteWhen: EMPTY`)일 경우, 잔류 유저에게 방장 권한 인계. - - **수동 위임 (Manual)**: 방장이 컨트롤 패널 버튼을 통해 직접 타겟 유저를 지정하여 위임. + - **수동 위임 (Manual)**: 방장이 드롭다운 메뉴를 통해 직접 타겟 유저를 지정하여 위임. - **자동 위임 (Auto)**: 방장 퇴장 시 빈 방이 아니라면 봇이 남아있는 유저 중 서버 가입일이 가장 오래된(혹은 무작위) 유저에게 랜덤으로 위임. - **상태 전이 정책**: 방 이름만 "새 방장의 이름 설정"으로 덮어씌우고, 기존 방장이 걸었던 '잠금(Lock)', '인원수 제한(Limit)', '차단(Ban)' 등의 보안 및 인원 설정은 변경하지 않고 그대로 현상 유지함(새 방장이 임의 조작 가능). ### 4. 개발 방향 (Proposed Actions) - `Prisma`를 통해 `VoiceGenerator`와 `TempVoiceChannel`, 티어 검증 로직 반영. - `voiceStateUpdate` 이벤트 훅 기반의 동적 음성 채널 제어 아키텍처 수립. -- `interactionCreate`에서 Modal 및 SelectMenu를 처리하여 권한 조작. +- `interactionCreate`에서 StringSelectMenu와 Modal을 통해 통합 처리하여 권한 조작. diff --git a/Docs/Troubleshooting/50013_Missing_Permissions.md b/Docs/Troubleshooting/50013_Missing_Permissions.md new file mode 100644 index 0000000..bd69a3a --- /dev/null +++ b/Docs/Troubleshooting/50013_Missing_Permissions.md @@ -0,0 +1,20 @@ +# 50013 Missing Permissions (Voice Channel Creation) + +## 체인지로그 (Changelog) +- **2026-03-27**: 문서 최초 작성 + +## 본문 (Body) + +### 현상 (Symptom) +`/voice-setup`으로 등록한 채널에 유저가 입장하여 `VoiceService.handleJoin()`이 실행될 때, 아래와 같은 에러가 발생하며 임시 음성 채널이 생성되지 않음. +`DiscordAPIError[50013]: Missing Permissions` + +### 원인 (Cause) +Discord API에서는 봇이 특정 채널을 생성하면서 `permissionOverwrites`를 통해 타인에게 권한(예: `ManageRoles`, `Connect`, `ViewChannel`)을 부여하려면, **봇 본인(Bot Member)**도 해당 권한을 반드시 가지고 있어야 합니다. +사용자가 봇을 초대할 때 '연결(Connect)'이나 '역할 관리(ManageRoles)' 등의 기본/관리 권한을 누락한 채 초대하였거나, 생성용 음성 채널이 위치한 카테고리가 봇의 권한을 덮어써서 발생합니다. + +### 해결 (Solution) +1. **코드 레벨 방어 (Fallback Mechanism)** + 권한 덮어쓰기(`permissionOverwrites`)가 실패할 경우 예외를 캐치(Catch)하여, **순정 채널(오버라이드 없는 상태)**로라도 채널이 무사히 생성되도록 로직 분기점(`VoiceService.ts` 내)을 구축했습니다. +2. **권한 안내 (User Guidance)** + 인게임 채널 관리(이름 변경, 킥, 한도 제한 등)는 봇이 채팅창에 제공하는 **컨트롤 패널(인터랙션 드롭다운 UI)**을 통해 봇이 대리로 수행하므로, 방장이 굳이 디스코드 순정 채널 내 관리 오버라이드 권한을 직접 받지 않아도 기능 이용에 지장이 없도록 유연하게 재설계 되었습니다. diff --git a/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md b/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md index 4f80d9f..ddad0ba 100644 --- a/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md +++ b/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md @@ -17,7 +17,8 @@ - `src/commands/voiceSetup.ts`: 특정 채널을 생성용(Generator)으로 지정하는 슬래시 커맨드. - `src/events/voiceStateUpdate.ts`, `src/services/VoiceService.ts`: 유저 접속 및 퇴장 이벤트를 감지하여 데이터베이스 검증을 거친 후 동적으로 채널을 생성/삭제하는 핵심 로직 구현. - **인터랙션/UI 개발**: - - `src/events/interactionCreate.ts`: 생성된 임시 채널 채팅창에 띄워진 조작 패널(버튼 5종)에 대응하는 Modal 및 Select Menu 핸들러 추가. (이름 변경, 인원수 제한, Lock/Unlock, Kick, Ban) + - `src/events/interactionCreate.ts`: 생성된 임시 채널 채팅창에 띄워진 조작 패널(단일 드롭다운 StringSelectMenu)에 대응하는 Modal 및 Select Menu 핸들러 추가. (이름 변경, 인원수 제한, Lock/Unlock, Kick, Ban) + - 사용자 피드백 반영: 조작 패널을 기존 6개 버튼에서 단일 드롭다운(StringSelectMenu)으로 리팩토링하여 UI 깔끔함 확보. ### 3. 🤔 주요 의사결정 (Decisions Made) - **차단(Ban) 동작 정의**: 특정 인터랙션에서 유저 밴 기능을 수행할 경우, 음성 채널 설정(Overwrite) API를 사용하여 해당 유저의 `Connect`, `ViewChannel` 권한을 모두 박탈함으로써 채널 텍스트 캐시 및 접속을 완전히 보이지 않게(투명화) 설계함. @@ -31,3 +32,6 @@ - *문제*: 로컬 구동 테스트 중 `Error: Used disallowed intents` 발생. - *원인*: `KordClient` 부팅 시 `GuildMembers`, `MessageContent`, `Presence` 세 가지 Privileged Gateway Intent를 요구하나 디스코드 온라인 설정상 비활성화되어 있음. - *해결*: 개발 과정에선 소스 오류가 아님을 확인 후, 사용자에게 디스코드 포털에서의 활성화 작업 요청 조치. +- **채널 생성 권한 충돌 (50013: Missing Permissions)**: + - *문제*: 봇이 임시 채널을 생성할 때 방장에게 `ManageRoles`, `ManageChannels` 등의 오버라이드 권한을 주려 했으나, 봇 자신에게 해당 권한이 서버 수준에서 부족하여 생성 실패 에러 발생. + - *해결*: 권한 덮어쓰기 로직에 try-catch 래핑을 추가하여 실패 시 권한 오버라이드를 제외한 순정 채널로 생성하도록 우회 해결. 자세한 사항은 [50013_Missing_Permissions.md](../Troubleshooting/50013_Missing_Permissions.md) 참고. diff --git a/Docs/index.md b/Docs/index.md index f1c25ff..12d1c08 100644 --- a/Docs/index.md +++ b/Docs/index.md @@ -14,6 +14,9 @@ ## 아키텍처 및 정책 결정 (Decisions) - [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md) +## 트러블슈팅 (Troubleshooting) +- [Voice Channel Missing Permissions (50013) 해결건](Troubleshooting/50013_Missing_Permissions.md) + ## 진행/완료 내역 (Work Done) - [2026-03-27: 임시 음성 채널 기능 구현 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md) diff --git a/src/commands/voiceSetup.ts b/src/commands/voiceSetup.ts index 4382292..f44b11d 100644 --- a/src/commands/voiceSetup.ts +++ b/src/commands/voiceSetup.ts @@ -7,48 +7,106 @@ export default { .setName('voice-setup') .setDescription('Setup a generator voice channel for temporary channels.') .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .addChannelOption(option => - option.setName('channel') - .setDescription('The voice channel to act as the Generator') - .setRequired(true) - .addChannelTypes(ChannelType.GuildVoice) + .addSubcommand(subcommand => + subcommand + .setName('set') + .setDescription('Set an existing voice channel as a Generator') + .addChannelOption(option => + option.setName('channel') + .setDescription('The voice channel to act as the Generator') + .setRequired(true) + .addChannelTypes(ChannelType.GuildVoice) + ) + .addChannelOption(option => + option.setName('category') + .setDescription('(Optional) The category where temp channels will be created') + .setRequired(false) + .addChannelTypes(ChannelType.GuildCategory) + ) ) - .addChannelOption(option => - option.setName('category') - .setDescription('(Optional) The category where temp channels will be created') - .setRequired(false) - .addChannelTypes(ChannelType.GuildCategory) + .addSubcommand(subcommand => + subcommand + .setName('create') + .setDescription('Create a new voice channel and set it as a Generator') + .addStringOption(option => + option.setName('name') + .setDescription('The name of the new generator voice channel') + .setRequired(true) + ) + .addChannelOption(option => + option.setName('category') + .setDescription('(Optional) The category where the new channel will be created') + .setRequired(false) + .addChannelTypes(ChannelType.GuildCategory) + ) ), async execute(interaction: ChatInputCommandInteraction) { - const channel = interaction.options.getChannel('channel', true); + const subcommand = interaction.options.getSubcommand(); const category = interaction.options.getChannel('category'); // In a real application, we would check the user's tier here. // E.g. const tier = await prisma.userSubscription.findUnique(...) // And const count = await prisma.voiceGenerator.count(...) - try { - await prisma.voiceGenerator.upsert({ - where: { channelId: channel.id }, - update: { categoryId: category?.id || null, guildId: interaction.guildId! }, - create: { - channelId: channel.id, - guildId: interaction.guildId!, - categoryId: category?.id || null, - } - }); + if (subcommand === 'set') { + const channel = interaction.options.getChannel('channel', true); - await interaction.reply({ - content: `Successfully set up ${channel} as a Voice Generator Channel!`, - ephemeral: true - }); - } catch (error) { - logger.error('Error in voice-setup command', error); - await interaction.reply({ - content: 'Failed to save the configuration to the database.', - ephemeral: true - }); + try { + await prisma.voiceGenerator.upsert({ + where: { channelId: channel.id }, + update: { categoryId: category?.id || null, guildId: interaction.guildId! }, + create: { + channelId: channel.id, + guildId: interaction.guildId!, + categoryId: category?.id || null, + } + }); + + await interaction.reply({ + content: `Successfully set up ${channel} as a Voice Generator Channel!`, + ephemeral: true + }); + } catch (error) { + logger.error('Error in voice-setup set command', error); + await interaction.reply({ + content: 'Failed to save the configuration to the database.', + ephemeral: true + }); + } + } else if (subcommand === 'create') { + const name = interaction.options.getString('name', true); + + try { + const guild = interaction.guild!; + + // Create the new voice channel + const newChannel = await guild.channels.create({ + name: name, + type: ChannelType.GuildVoice, + parent: category?.id || null, + }); + + // Save to DB + await prisma.voiceGenerator.create({ + data: { + channelId: newChannel.id, + guildId: guild.id, + categoryId: category?.id || null, + } + }); + + await interaction.reply({ + content: `Successfully created and set up ${newChannel} as a Voice Generator Channel!`, + ephemeral: true + }); + } catch (error) { + logger.error('Error in voice-setup create command', error); + await interaction.reply({ + content: 'Failed to create the channel or save the configuration.', + ephemeral: true + }); + } } }, }; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 75f81b8..6772cdc 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -22,13 +22,13 @@ export default { } } } - else if (interaction.isButton()) { + else if (interaction.isStringSelectMenu()) { const customId = interaction.customId; - if (customId.startsWith('vc_')) { + if (customId.startsWith('vc_control_')) { const parts = customId.split('_'); - const action = parts[1]; const ownerId = parts[2]; + const action = interaction.values[0]; if (interaction.user.id !== ownerId) { return interaction.reply({ content: 'Only the channel owner can use these controls.', ephemeral: true }); diff --git a/src/services/VoiceService.ts b/src/services/VoiceService.ts index bac25df..2371619 100644 --- a/src/services/VoiceService.ts +++ b/src/services/VoiceService.ts @@ -1,4 +1,4 @@ -import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from 'discord.js'; import { prisma } from '../database'; import { logger } from '../utils/logger'; @@ -29,8 +29,12 @@ export class VoiceService { if (!generator) return; const botMember = guild.members.me; - if (!botMember?.permissions.has(PermissionFlagsBits.ManageChannels)) { - logger.warn(`Bot lacks ManageChannels in guild ${guild.id}`); + if (!botMember?.permissions.has([ + PermissionFlagsBits.ManageChannels, + PermissionFlagsBits.ManageRoles, + PermissionFlagsBits.MoveMembers + ])) { + logger.warn(`Bot lacks required Voice permissions (Manage Channels, Manage Roles, Move Members) in guild ${guild.id}`); return; } @@ -54,26 +58,42 @@ export class VoiceService { try { const parentId = generator.categoryId || state.channel?.parentId || undefined; - const newChannel = await guild.channels.create({ - name: channelName, - type: ChannelType.GuildVoice, - parent: parentId, - userLimit: userLimit, - permissionOverwrites: [ - { - id: guild.roles.everyone.id, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], - }, - { - id: member.id, - allow: [ - PermissionFlagsBits.ManageChannels, - PermissionFlagsBits.ManageRoles, - PermissionFlagsBits.MoveMembers, - ], - }, - ], - }); + let newChannel; + try { + newChannel = await guild.channels.create({ + name: channelName, + type: ChannelType.GuildVoice, + parent: parentId, + userLimit: userLimit, + permissionOverwrites: [ + { + id: guild.roles.everyone.id, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], + }, + { + id: member.id, + allow: [ + PermissionFlagsBits.ManageChannels, + PermissionFlagsBits.ManageRoles, + PermissionFlagsBits.MoveMembers, + ], + }, + ], + }); + } catch (firstError: any) { + if (firstError.code === 50013) { + logger.warn(`Missing Permissions when creating with overwrites. Bot perms: ${botMember?.permissions.bitfield.toString()}. Retrying without overwrites...`); + newChannel = await guild.channels.create({ + name: channelName, + type: ChannelType.GuildVoice, + parent: parentId, + userLimit: userLimit, + }); + logger.info(`Channel created WITHOUT overwrites successfully.`); + } else { + throw firstError; + } + } await prisma.tempVoiceChannel.create({ data: { @@ -168,22 +188,24 @@ export class VoiceService { public static async sendControlPanel(channel: VoiceChannel, ownerId: string) { - const row1 = new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId(`vc_rename_${ownerId}`).setLabel('Rename').setStyle(ButtonStyle.Primary), - new ButtonBuilder().setCustomId(`vc_kick_${ownerId}`).setLabel('Kick').setStyle(ButtonStyle.Danger), - new ButtonBuilder().setCustomId(`vc_ban_${ownerId}`).setLabel('Ban/Hide').setStyle(ButtonStyle.Danger), - new ButtonBuilder().setCustomId(`vc_limit_${ownerId}`).setLabel('Limit').setStyle(ButtonStyle.Secondary), - new ButtonBuilder().setCustomId(`vc_lock_${ownerId}`).setLabel('Lock/Unlock').setStyle(ButtonStyle.Secondary) - ); + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(`vc_control_${ownerId}`) + .setPlaceholder('⚙️ Manage Channel Settings') + .addOptions( + new StringSelectMenuOptionBuilder().setLabel('Rename Channel').setValue('rename').setEmoji('✏️'), + new StringSelectMenuOptionBuilder().setLabel('Set User Limit').setValue('limit').setEmoji('👥'), + new StringSelectMenuOptionBuilder().setLabel('Lock / Unlock').setValue('lock').setEmoji('🔒'), + new StringSelectMenuOptionBuilder().setLabel('Kick User').setValue('kick').setEmoji('👢'), + new StringSelectMenuOptionBuilder().setLabel('Ban / Hide User').setValue('ban').setEmoji('🚫'), + new StringSelectMenuOptionBuilder().setLabel('Transfer Ownership').setValue('transfer').setEmoji('👑') + ); - const row2 = new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId(`vc_transfer_${ownerId}`).setLabel('Transfer Ownership').setStyle(ButtonStyle.Success) - ); + const row = new ActionRowBuilder().addComponents(selectMenu); try { await channel.send({ - content: `<@${ownerId}>, your temporary channel is ready! Use the buttons below to manage it.`, - components: [row1, row2] + content: `<@${ownerId}>, your temporary channel is ready! Use the dropdown menu below to manage it.`, + components: [row] }).catch(() => {}); } catch (e) { logger.error('Failed to send control panel UI', e);