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:
parent
efe6a10762
commit
b5b6405d0c
|
|
@ -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을 통해 통합 처리하여 권한 조작.
|
||||||
|
|
|
||||||
|
|
@ -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)**을 통해 봇이 대리로 수행하므로, 방장이 굳이 디스코드 순정 채널 내 관리 오버라이드 권한을 직접 받지 않아도 기능 이용에 지장이 없도록 유연하게 재설계 되었습니다.
|
||||||
|
|
@ -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) 참고.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue