feat: Refactor voice channel controls to a select menu, enhance `voice-setup` with subcommands, and improve voice channel creation with permission error handling.

This commit is contained in:
이정수 2026-03-27 13:23:34 +09:00
parent efe6a10762
commit b5b6405d0c
7 changed files with 181 additions and 74 deletions

View File

@ -1,7 +1,7 @@
# Kord - 임시 음성 채널 기능 기획 (Temporary Voice Channels) # Kord - 임시 음성 채널 기능 기획 (Temporary Voice Channels)
## 체인지로그 (Changelog) ## 체인지로그 (Changelog)
- **2026-03-27**: 최초 기획서 작성 및 확정 - **2026-03-27**: 최초 기획서 작성 및 확정 / UI 드롭다운(Select Menu) 형태로 개편 반영
## 본문 (Body) ## 본문 (Body)
@ -16,7 +16,7 @@
(자세한 티어 정책은 **`Docs/Decisions/subscription_tiers.md`** 참고) (자세한 티어 정책은 **`Docs/Decisions/subscription_tiers.md`** 참고)
### 3. 임시 채널 관리 인터랙션 (UI) ### 3. 임시 채널 관리 인터랙션 (UI)
임시 채널이 생성되면, 해당 음성 채널의 '텍스트 채팅(Voice Text Channel)' 영역에 **방장 전용 관리 인터랙션(버튼, Select Menu)** 이 전송됩니다. 임시 채널이 생성되면, 해당 음성 채널의 '텍스트 채팅(Voice Text Channel)' 영역에 **방장 전용 관리 인터랙션(드롭다운 형태의 Select Menu)** 이 전송됩니다.
#### 기본 기능 (Essential) #### 기본 기능 (Essential)
1. **채널 명 변경**: 임시 채널의 이름 변경. (DB에 저장되어 추후 재생성 시에도 해당 이름으로 복구) 1. **채널 명 변경**: 임시 채널의 이름 변경. (DB에 저장되어 추후 재생성 시에도 해당 이름으로 복구)
@ -31,11 +31,11 @@
1. **인원수 제한 (User Limit)**: 슬라이더나 모달을 통해 채널 최대 인원 설정 (1~99명). 1. **인원수 제한 (User Limit)**: 슬라이더나 모달을 통해 채널 최대 인원 설정 (1~99명).
2. **잠금/해제 (Lock/Unlock)**: 외부인의 접속을 완전히 차단하고, 방장이 초대한 사람만 들어올 수 있게 전환. 2. **잠금/해제 (Lock/Unlock)**: 외부인의 접속을 완전히 차단하고, 방장이 초대한 사람만 들어올 수 있게 전환.
3. **주인 위임 (Transfer Ownership)**: 방장이 나가더라도 채널이 유지되는 조건(`deleteWhen: EMPTY`)일 경우, 잔류 유저에게 방장 권한 인계. 3. **주인 위임 (Transfer Ownership)**: 방장이 나가더라도 채널이 유지되는 조건(`deleteWhen: EMPTY`)일 경우, 잔류 유저에게 방장 권한 인계.
- **수동 위임 (Manual)**: 방장이 컨트롤 패널 버튼을 통해 직접 타겟 유저를 지정하여 위임. - **수동 위임 (Manual)**: 방장이 드롭다운 메뉴를 통해 직접 타겟 유저를 지정하여 위임.
- **자동 위임 (Auto)**: 방장 퇴장 시 빈 방이 아니라면 봇이 남아있는 유저 중 서버 가입일이 가장 오래된(혹은 무작위) 유저에게 랜덤으로 위임. - **자동 위임 (Auto)**: 방장 퇴장 시 빈 방이 아니라면 봇이 남아있는 유저 중 서버 가입일이 가장 오래된(혹은 무작위) 유저에게 랜덤으로 위임.
- **상태 전이 정책**: 방 이름만 "새 방장의 이름 설정"으로 덮어씌우고, 기존 방장이 걸었던 '잠금(Lock)', '인원수 제한(Limit)', '차단(Ban)' 등의 보안 및 인원 설정은 변경하지 않고 그대로 현상 유지함(새 방장이 임의 조작 가능). - **상태 전이 정책**: 방 이름만 "새 방장의 이름 설정"으로 덮어씌우고, 기존 방장이 걸었던 '잠금(Lock)', '인원수 제한(Limit)', '차단(Ban)' 등의 보안 및 인원 설정은 변경하지 않고 그대로 현상 유지함(새 방장이 임의 조작 가능).
### 4. 개발 방향 (Proposed Actions) ### 4. 개발 방향 (Proposed Actions)
- `Prisma`를 통해 `VoiceGenerator``TempVoiceChannel`, 티어 검증 로직 반영. - `Prisma`를 통해 `VoiceGenerator``TempVoiceChannel`, 티어 검증 로직 반영.
- `voiceStateUpdate` 이벤트 훅 기반의 동적 음성 채널 제어 아키텍처 수립. - `voiceStateUpdate` 이벤트 훅 기반의 동적 음성 채널 제어 아키텍처 수립.
- `interactionCreate`에서 Modal 및 SelectMenu를 처리하여 권한 조작. - `interactionCreate`에서 StringSelectMenu와 Modal을 통해 통합 처리하여 권한 조작.

View File

@ -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)**을 통해 봇이 대리로 수행하므로, 방장이 굳이 디스코드 순정 채널 내 관리 오버라이드 권한을 직접 받지 않아도 기능 이용에 지장이 없도록 유연하게 재설계 되었습니다.

View File

@ -17,7 +17,8 @@
- `src/commands/voiceSetup.ts`: 특정 채널을 생성용(Generator)으로 지정하는 슬래시 커맨드. - `src/commands/voiceSetup.ts`: 특정 채널을 생성용(Generator)으로 지정하는 슬래시 커맨드.
- `src/events/voiceStateUpdate.ts`, `src/services/VoiceService.ts`: 유저 접속 및 퇴장 이벤트를 감지하여 데이터베이스 검증을 거친 후 동적으로 채널을 생성/삭제하는 핵심 로직 구현. - `src/events/voiceStateUpdate.ts`, `src/services/VoiceService.ts`: 유저 접속 및 퇴장 이벤트를 감지하여 데이터베이스 검증을 거친 후 동적으로 채널을 생성/삭제하는 핵심 로직 구현.
- **인터랙션/UI 개발**: - **인터랙션/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) ### 3. 🤔 주요 의사결정 (Decisions Made)
- **차단(Ban) 동작 정의**: 특정 인터랙션에서 유저 밴 기능을 수행할 경우, 음성 채널 설정(Overwrite) API를 사용하여 해당 유저의 `Connect`, `ViewChannel` 권한을 모두 박탈함으로써 채널 텍스트 캐시 및 접속을 완전히 보이지 않게(투명화) 설계함. - **차단(Ban) 동작 정의**: 특정 인터랙션에서 유저 밴 기능을 수행할 경우, 음성 채널 설정(Overwrite) API를 사용하여 해당 유저의 `Connect`, `ViewChannel` 권한을 모두 박탈함으로써 채널 텍스트 캐시 및 접속을 완전히 보이지 않게(투명화) 설계함.
@ -31,3 +32,6 @@
- *문제*: 로컬 구동 테스트 중 `Error: Used disallowed intents` 발생. - *문제*: 로컬 구동 테스트 중 `Error: Used disallowed intents` 발생.
- *원인*: `KordClient` 부팅 시 `GuildMembers`, `MessageContent`, `Presence` 세 가지 Privileged Gateway Intent를 요구하나 디스코드 온라인 설정상 비활성화되어 있음. - *원인*: `KordClient` 부팅 시 `GuildMembers`, `MessageContent`, `Presence` 세 가지 Privileged Gateway Intent를 요구하나 디스코드 온라인 설정상 비활성화되어 있음.
- *해결*: 개발 과정에선 소스 오류가 아님을 확인 후, 사용자에게 디스코드 포털에서의 활성화 작업 요청 조치. - *해결*: 개발 과정에선 소스 오류가 아님을 확인 후, 사용자에게 디스코드 포털에서의 활성화 작업 요청 조치.
- **채널 생성 권한 충돌 (50013: Missing Permissions)**:
- *문제*: 봇이 임시 채널을 생성할 때 방장에게 `ManageRoles`, `ManageChannels` 등의 오버라이드 권한을 주려 했으나, 봇 자신에게 해당 권한이 서버 수준에서 부족하여 생성 실패 에러 발생.
- *해결*: 권한 덮어쓰기 로직에 try-catch 래핑을 추가하여 실패 시 권한 오버라이드를 제외한 순정 채널로 생성하도록 우회 해결. 자세한 사항은 [50013_Missing_Permissions.md](../Troubleshooting/50013_Missing_Permissions.md) 참고.

View File

@ -14,6 +14,9 @@
## 아키텍처 및 정책 결정 (Decisions) ## 아키텍처 및 정책 결정 (Decisions)
- [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md) - [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md)
## 트러블슈팅 (Troubleshooting)
- [Voice Channel Missing Permissions (50013) 해결건](Troubleshooting/50013_Missing_Permissions.md)
## 진행/완료 내역 (Work Done) ## 진행/완료 내역 (Work Done)
- [2026-03-27: 임시 음성 채널 기능 구현 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md) - [2026-03-27: 임시 음성 채널 기능 구현 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md)

View File

@ -7,6 +7,10 @@ export default {
.setName('voice-setup') .setName('voice-setup')
.setDescription('Setup a generator voice channel for temporary channels.') .setDescription('Setup a generator voice channel for temporary channels.')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addSubcommand(subcommand =>
subcommand
.setName('set')
.setDescription('Set an existing voice channel as a Generator')
.addChannelOption(option => .addChannelOption(option =>
option.setName('channel') option.setName('channel')
.setDescription('The voice channel to act as the Generator') .setDescription('The voice channel to act as the Generator')
@ -18,16 +22,36 @@ export default {
.setDescription('(Optional) The category where temp channels will be created') .setDescription('(Optional) The category where temp channels will be created')
.setRequired(false) .setRequired(false)
.addChannelTypes(ChannelType.GuildCategory) .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) { async execute(interaction: ChatInputCommandInteraction) {
const channel = interaction.options.getChannel('channel', true); const subcommand = interaction.options.getSubcommand();
const category = interaction.options.getChannel('category'); const category = interaction.options.getChannel('category');
// In a real application, we would check the user's tier here. // In a real application, we would check the user's tier here.
// E.g. const tier = await prisma.userSubscription.findUnique(...) // E.g. const tier = await prisma.userSubscription.findUnique(...)
// And const count = await prisma.voiceGenerator.count(...) // And const count = await prisma.voiceGenerator.count(...)
if (subcommand === 'set') {
const channel = interaction.options.getChannel('channel', true);
try { try {
await prisma.voiceGenerator.upsert({ await prisma.voiceGenerator.upsert({
where: { channelId: channel.id }, where: { channelId: channel.id },
@ -44,11 +68,45 @@ export default {
ephemeral: true ephemeral: true
}); });
} catch (error) { } catch (error) {
logger.error('Error in voice-setup command', error); logger.error('Error in voice-setup set command', error);
await interaction.reply({ await interaction.reply({
content: 'Failed to save the configuration to the database.', content: 'Failed to save the configuration to the database.',
ephemeral: true 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
});
}
}
}, },
}; };

View File

@ -22,13 +22,13 @@ export default {
} }
} }
} }
else if (interaction.isButton()) { else if (interaction.isStringSelectMenu()) {
const customId = interaction.customId; const customId = interaction.customId;
if (customId.startsWith('vc_')) { if (customId.startsWith('vc_control_')) {
const parts = customId.split('_'); const parts = customId.split('_');
const action = parts[1];
const ownerId = parts[2]; const ownerId = parts[2];
const action = interaction.values[0];
if (interaction.user.id !== ownerId) { if (interaction.user.id !== ownerId) {
return interaction.reply({ content: 'Only the channel owner can use these controls.', ephemeral: true }); return interaction.reply({ content: 'Only the channel owner can use these controls.', ephemeral: true });

View File

@ -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 { prisma } from '../database';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
@ -29,8 +29,12 @@ export class VoiceService {
if (!generator) return; if (!generator) return;
const botMember = guild.members.me; const botMember = guild.members.me;
if (!botMember?.permissions.has(PermissionFlagsBits.ManageChannels)) { if (!botMember?.permissions.has([
logger.warn(`Bot lacks ManageChannels in guild ${guild.id}`); PermissionFlagsBits.ManageChannels,
PermissionFlagsBits.ManageRoles,
PermissionFlagsBits.MoveMembers
])) {
logger.warn(`Bot lacks required Voice permissions (Manage Channels, Manage Roles, Move Members) in guild ${guild.id}`);
return; return;
} }
@ -54,7 +58,9 @@ export class VoiceService {
try { try {
const parentId = generator.categoryId || state.channel?.parentId || undefined; const parentId = generator.categoryId || state.channel?.parentId || undefined;
const newChannel = await guild.channels.create({ let newChannel;
try {
newChannel = await guild.channels.create({
name: channelName, name: channelName,
type: ChannelType.GuildVoice, type: ChannelType.GuildVoice,
parent: parentId, parent: parentId,
@ -74,6 +80,20 @@ export class VoiceService {
}, },
], ],
}); });
} 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({ await prisma.tempVoiceChannel.create({
data: { data: {
@ -168,22 +188,24 @@ export class VoiceService {
public static async sendControlPanel(channel: VoiceChannel, ownerId: string) { public static async sendControlPanel(channel: VoiceChannel, ownerId: string) {
const row1 = new ActionRowBuilder<ButtonBuilder>().addComponents( const selectMenu = new StringSelectMenuBuilder()
new ButtonBuilder().setCustomId(`vc_rename_${ownerId}`).setLabel('Rename').setStyle(ButtonStyle.Primary), .setCustomId(`vc_control_${ownerId}`)
new ButtonBuilder().setCustomId(`vc_kick_${ownerId}`).setLabel('Kick').setStyle(ButtonStyle.Danger), .setPlaceholder('⚙️ Manage Channel Settings')
new ButtonBuilder().setCustomId(`vc_ban_${ownerId}`).setLabel('Ban/Hide').setStyle(ButtonStyle.Danger), .addOptions(
new ButtonBuilder().setCustomId(`vc_limit_${ownerId}`).setLabel('Limit').setStyle(ButtonStyle.Secondary), new StringSelectMenuOptionBuilder().setLabel('Rename Channel').setValue('rename').setEmoji('✏️'),
new ButtonBuilder().setCustomId(`vc_lock_${ownerId}`).setLabel('Lock/Unlock').setStyle(ButtonStyle.Secondary) 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<ButtonBuilder>().addComponents( const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu);
new ButtonBuilder().setCustomId(`vc_transfer_${ownerId}`).setLabel('Transfer Ownership').setStyle(ButtonStyle.Success)
);
try { try {
await channel.send({ await channel.send({
content: `<@${ownerId}>, your temporary channel is ready! Use the buttons below to manage it.`, content: `<@${ownerId}>, your temporary channel is ready! Use the dropdown menu below to manage it.`,
components: [row1, row2] components: [row]
}).catch(() => {}); }).catch(() => {});
} catch (e) { } catch (e) {
logger.error('Failed to send control panel UI', e); logger.error('Failed to send control panel UI', e);