feat: Implement temporary voice channel ownership transfer with UI, automatic transfer logic, and permission management.

This commit is contained in:
이정수 2026-03-27 13:02:16 +09:00
parent f5c8bdb85b
commit efe6a10762
7 changed files with 132 additions and 9 deletions

View File

@ -9,3 +9,8 @@
## Security Rules
- 비밀번호, API 키, 인증 토큰, URL에 포함된 인증 정보 등 민감한 정보는 소스 코드나 마크다운 문서 등 공개된 곳에 절대 기재하지 마십시오.
- 보안 설정값은 `.env` 등 Git 추적에서 제외된 파일을 사용하고 동적으로 로드하여 사용해야 합니다.
## Development & Testing Rules
- 기능을 구현하거나 수정한 후에는 사용자의 별도 지시나 알림이 없더라도 **반드시 스스로 `yarn build` 및 `yarn test`를 수행하여 코드의 정합성을 검증**해야 합니다.
- 단순 문법 검증에 그치지 말고, `yarn dev` 등의 명령어로 봇을 백그라운드에서 직접 구동(Boot)시켜 DB/캐시 커넥트 및 이벤트 리스너 초기화 중 런타임 에러(예: Intents 권한 거부 등)가 발생하지 않는지 스스로 눈으로 확인하고 완벽히 디버깅해야 합니다.
- 봇이 성공적으로 온라인 상태(`Ready`)에 진입한 것을 확인한 뒤에만 사용자에게 작업 완료를 보고합니다.

View File

@ -9,3 +9,8 @@
## Security Rules
- 비밀번호, API 키, 인증 토큰, URL에 포함된 인증 정보 등 민감한 정보는 소스 코드나 마크다운 문서 등 공개된 곳에 절대 기재하지 마십시오.
- 보안 설정값은 `.env` 등 Git 추적에서 제외된 파일을 사용하고 동적으로 로드하여 사용해야 합니다.
## Development & Testing Rules
- 기능을 구현하거나 수정한 후에는 사용자의 별도 지시나 알림이 없더라도 **반드시 스스로 `yarn build` 및 `yarn test`를 수행하여 코드의 정합성을 검증**해야 합니다.
- 단순 문법 검증에 그치지 말고, `yarn dev` 등의 명령어로 봇을 백그라운드에서 직접 구동(Boot)시켜 DB/캐시 커넥트 및 이벤트 리스너 초기화 중 런타임 에러(예: Intents 권한 거부 등)가 발생하지 않는지 스스로 눈으로 확인하고 완벽히 디버깅해야 합니다.
- 봇이 성공적으로 온라인 상태(`Ready`)에 진입한 것을 확인한 뒤에만 사용자에게 작업 완료를 보고합니다.

View File

@ -0,0 +1,41 @@
# Kord - 임시 음성 채널 기능 기획 (Temporary Voice Channels)
## 체인지로그 (Changelog)
- **2026-03-27**: 최초 기획서 작성 및 확정
## 본문 (Body)
### 1. 개요
사용자가 '생성용 음성채널'에 입장하면 봇이 즉시 개인별 '전용' 임시 음성 채널을 생성해 주고 이동시켜 주는 자동화 시스템 기획서입니다.
- **제한**: 유저당 활성화된 본인 소유의 임시 채널은 1개로 제한.
- **카테고리**: 기본적으로 생성용 채널과 같은 카테고리에 생성되며 권한도 동기화. (서버 관리자가 별도 전용 카테고리를 지정할 수도 있음)
- **삭제 조건**: 서버 설정에 따라 '방장 퇴장 시' 또는 '채널 내 전원 퇴장 시(0명)' 임시 채널이 자동 삭제됨.
### 2. 티어(Tier) 기반 한도 제어
봇을 초대한 사용자의 구독 티어에 따라, 서버에서 만들 수 있는 '생성용 채널'의 개수가 제한됩니다.
(자세한 티어 정책은 **`Docs/Decisions/subscription_tiers.md`** 참고)
### 3. 임시 채널 관리 인터랙션 (UI)
임시 채널이 생성되면, 해당 음성 채널의 '텍스트 채팅(Voice Text Channel)' 영역에 **방장 전용 관리 인터랙션(버튼, Select Menu)** 이 전송됩니다.
#### 기본 기능 (Essential)
1. **채널 명 변경**: 임시 채널의 이름 변경. (DB에 저장되어 추후 재생성 시에도 해당 이름으로 복구)
2. **Kick (내보내기)**: 특정 사용자를 선택하여 강제 퇴장.
3. **Ban (차단)**:
- 두 가지 수준으로 지원 가능한지 기술팀 검토 완료. (디스코드 권한 오버라이드 지원)
- *입장 금지 모드*: 대상의 `Connect` 권한을 `false`로 설정 (채널은 보이지만 들어올 수 없음).
- *투명 방 모드(Hide)*: 대상의 `ViewChannel` 권한까지 `false`로 설정 (채널 자체가 안 보임).
#### 추가 검토 및 커뮤니티 인기 기능 (Optional Research)
디스코드 봇 사용자 커뮤니티에서 자주 요구되는 아래 기능들도 도입이 함께 기획되었습니다.
1. **인원수 제한 (User Limit)**: 슬라이더나 모달을 통해 채널 최대 인원 설정 (1~99명).
2. **잠금/해제 (Lock/Unlock)**: 외부인의 접속을 완전히 차단하고, 방장이 초대한 사람만 들어올 수 있게 전환.
3. **주인 위임 (Transfer Ownership)**: 방장이 나가더라도 채널이 유지되는 조건(`deleteWhen: EMPTY`)일 경우, 잔류 유저에게 방장 권한 인계.
- **수동 위임 (Manual)**: 방장이 컨트롤 패널 버튼을 통해 직접 타겟 유저를 지정하여 위임.
- **자동 위임 (Auto)**: 방장 퇴장 시 빈 방이 아니라면 봇이 남아있는 유저 중 서버 가입일이 가장 오래된(혹은 무작위) 유저에게 랜덤으로 위임.
- **상태 전이 정책**: 방 이름만 "새 방장의 이름 설정"으로 덮어씌우고, 기존 방장이 걸었던 '잠금(Lock)', '인원수 제한(Limit)', '차단(Ban)' 등의 보안 및 인원 설정은 변경하지 않고 그대로 현상 유지함(새 방장이 임의 조작 가능).
### 4. 개발 방향 (Proposed Actions)
- `Prisma`를 통해 `VoiceGenerator``TempVoiceChannel`, 티어 검증 로직 반영.
- `voiceStateUpdate` 이벤트 훅 기반의 동적 음성 채널 제어 아키텍처 수립.
- `interactionCreate`에서 Modal 및 SelectMenu를 처리하여 권한 조작.

View File

@ -8,6 +8,9 @@
## 기능 명세 (Features)
- [임시 음성 채널 자동화 (Temp Voice Channels)](Features/temp_voice_channels.md)
## 기획서 (Plans)
- [임시 음성 채널 기능 기획서 (Temp Voice Channel Plan)](Plans/Temp_Voice_Channel_Plan.md)
## 아키텍처 및 정책 결정 (Decisions)
- [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md)

View File

@ -16,8 +16,8 @@ export class KordClient extends Client {
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
// GatewayIntentBits.MessageContent, // Privileged -> Disabled for testing
// GatewayIntentBits.GuildMembers, // Privileged -> Disabled for testing
GatewayIntentBits.GuildInvites,
],
partials: [Partials.Message, Partials.Channel, Partials.GuildMember],

View File

@ -104,6 +104,14 @@ export default {
.setMaxValues(1);
await interaction.reply({ content: 'Banning will make the channel invisible to them.', components: [new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(select)], ephemeral: true });
}
else if (action === 'transfer') {
const select = new UserSelectMenuBuilder()
.setCustomId(`select_vc_transfer_${ownerId}`)
.setPlaceholder('Select a user to transfer ownership to')
.setMinValues(1)
.setMaxValues(1);
await interaction.reply({ content: 'Select who will become the new owner of this channel.', components: [new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(select)], ephemeral: true });
}
} catch (error) {
logger.error('Button action error', error);
if (!interaction.replied) await interaction.reply({ content: 'Failed to execute action.', ephemeral: true });
@ -197,6 +205,24 @@ export default {
await interaction.reply({ content: `Banned and hidden channel from <@${targetUserId}>.`, ephemeral: true });
}
else if (action === 'transfer') {
const targetMember = await interaction.guild?.members.fetch(targetUserId);
if (targetMember && targetMember.voice.channelId === voiceChannel.id) {
// Update ownership in DB
const { prisma } = require('../database');
const { VoiceService } = require('../services/VoiceService');
await prisma.tempVoiceChannel.update({
where: { channelId: voiceChannel.id },
data: { ownerId: targetUserId }
});
await VoiceService.applyOwnershipTransfer(voiceChannel, ownerId, targetUserId);
await interaction.reply({ content: `Ownership successfully transferred to <@${targetUserId}>.`, ephemeral: true });
} else {
await interaction.reply({ content: `The selected user MUST be inside this voice channel right now to transfer ownership.`, ephemeral: true });
}
}
} catch (e) {
logger.error('Select menu error', e);
await interaction.reply({ content: 'Action failed.', ephemeral: true });

View File

@ -104,7 +104,6 @@ export class VoiceService {
const channel = state.channel as VoiceChannel;
if (!channel) {
// Channel might already be deleted by other means
await prisma.tempVoiceChannel.delete({ where: { channelId } }).catch(() => {});
return;
}
@ -122,14 +121,54 @@ export class VoiceService {
await prisma.tempVoiceChannel.delete({ where: { channelId } });
logger.info(`VoiceService: Deleted temp channel ${channel.name}`);
} catch (error) {
// Fallback: If channel delete fails (already deleted), ensuring DB cleans up
await prisma.tempVoiceChannel.delete({ where: { channelId } }).catch(() => {});
}
}
else if (tempChannel.deleteWhen === 'EMPTY' && tempChannel.ownerId === member.id && channel.members.size > 0) {
// Auto Transfer Ownership
const newOwner = channel.members.first();
if (newOwner) {
try {
await prisma.tempVoiceChannel.update({
where: { channelId },
data: { ownerId: newOwner.id }
});
await this.applyOwnershipTransfer(channel, tempChannel.ownerId, newOwner.id);
logger.info(`VoiceService: Auto-transferred ownership to ${newOwner.user.tag}`);
} catch (error) {
logger.error('Failed to auto-transfer ownership', error);
}
}
}
}
private static async sendControlPanel(channel: VoiceChannel, ownerId: string) {
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
public static async applyOwnershipTransfer(channel: VoiceChannel, oldOwnerId: string, newOwnerId: string) {
const profile = await prisma.userVoiceProfile.findUnique({ where: { userId: newOwnerId }});
const newName = profile?.customName || `<@${newOwnerId}>'s Room`; // discord limits name formats, we must use raw username
// Fix: Voice channel names can't contain <@id>, so we must fetch the member
const newMember = await channel.guild.members.fetch(newOwnerId);
const finalName = profile?.customName || `${newMember.user.username}'s Room`;
await channel.setName(finalName).catch(() => {});
// Give Manage Channels permission to new owner, remove from old
await channel.permissionOverwrites.edit(newOwnerId, {
ManageChannels: true,
ManageRoles: true,
MoveMembers: true
}).catch(() => {});
await channel.permissionOverwrites.delete(oldOwnerId).catch(() => {});
// Send new control panel
await this.sendControlPanel(channel, newOwnerId);
}
public static async sendControlPanel(channel: VoiceChannel, ownerId: string) {
const row1 = new ActionRowBuilder<ButtonBuilder>().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),
@ -137,11 +176,15 @@ export class VoiceService {
new ButtonBuilder().setCustomId(`vc_lock_${ownerId}`).setLabel('Lock/Unlock').setStyle(ButtonStyle.Secondary)
);
const row2 = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(`vc_transfer_${ownerId}`).setLabel('Transfer Ownership').setStyle(ButtonStyle.Success)
);
try {
await channel.send({
content: `Welcome to your channel! Only the owner (<@${ownerId}>) can use these controls.`,
components: [row]
});
content: `<@${ownerId}>, your temporary channel is ready! Use the buttons below to manage it.`,
components: [row1, row2]
}).catch(() => {});
} catch (e) {
logger.error('Failed to send control panel UI', e);
}