diff --git a/.cursorrules b/.cursorrules index ad8aadd..38adfbc 100644 --- a/.cursorrules +++ b/.cursorrules @@ -9,3 +9,8 @@ ## Security Rules - 비밀번호, API 키, 인증 토큰, URL에 포함된 인증 정보 등 민감한 정보는 소스 코드나 마크다운 문서 등 공개된 곳에 절대 기재하지 마십시오. - 보안 설정값은 `.env` 등 Git 추적에서 제외된 파일을 사용하고 동적으로 로드하여 사용해야 합니다. + +## Development & Testing Rules +- 기능을 구현하거나 수정한 후에는 사용자의 별도 지시나 알림이 없더라도 **반드시 스스로 `yarn build` 및 `yarn test`를 수행하여 코드의 정합성을 검증**해야 합니다. +- 단순 문법 검증에 그치지 말고, `yarn dev` 등의 명령어로 봇을 백그라운드에서 직접 구동(Boot)시켜 DB/캐시 커넥트 및 이벤트 리스너 초기화 중 런타임 에러(예: Intents 권한 거부 등)가 발생하지 않는지 스스로 눈으로 확인하고 완벽히 디버깅해야 합니다. +- 봇이 성공적으로 온라인 상태(`Ready`)에 진입한 것을 확인한 뒤에만 사용자에게 작업 완료를 보고합니다. diff --git a/.windsurfrules b/.windsurfrules index ad8aadd..38adfbc 100644 --- a/.windsurfrules +++ b/.windsurfrules @@ -9,3 +9,8 @@ ## Security Rules - 비밀번호, API 키, 인증 토큰, URL에 포함된 인증 정보 등 민감한 정보는 소스 코드나 마크다운 문서 등 공개된 곳에 절대 기재하지 마십시오. - 보안 설정값은 `.env` 등 Git 추적에서 제외된 파일을 사용하고 동적으로 로드하여 사용해야 합니다. + +## Development & Testing Rules +- 기능을 구현하거나 수정한 후에는 사용자의 별도 지시나 알림이 없더라도 **반드시 스스로 `yarn build` 및 `yarn test`를 수행하여 코드의 정합성을 검증**해야 합니다. +- 단순 문법 검증에 그치지 말고, `yarn dev` 등의 명령어로 봇을 백그라운드에서 직접 구동(Boot)시켜 DB/캐시 커넥트 및 이벤트 리스너 초기화 중 런타임 에러(예: Intents 권한 거부 등)가 발생하지 않는지 스스로 눈으로 확인하고 완벽히 디버깅해야 합니다. +- 봇이 성공적으로 온라인 상태(`Ready`)에 진입한 것을 확인한 뒤에만 사용자에게 작업 완료를 보고합니다. diff --git a/Docs/Plans/Temp_Voice_Channel_Plan.md b/Docs/Plans/Temp_Voice_Channel_Plan.md new file mode 100644 index 0000000..e85e506 --- /dev/null +++ b/Docs/Plans/Temp_Voice_Channel_Plan.md @@ -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를 처리하여 권한 조작. diff --git a/Docs/index.md b/Docs/index.md index 28ac04a..f1c25ff 100644 --- a/Docs/index.md +++ b/Docs/index.md @@ -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) diff --git a/src/client/KordClient.ts b/src/client/KordClient.ts index c06a40e..bedfb6b 100644 --- a/src/client/KordClient.ts +++ b/src/client/KordClient.ts @@ -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], diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 02d1e8f..75f81b8 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -104,6 +104,14 @@ export default { .setMaxValues(1); await interaction.reply({ content: 'Banning will make the channel invisible to them.', components: [new ActionRowBuilder().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().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 }); diff --git a/src/services/VoiceService.ts b/src/services/VoiceService.ts index 34500dd..bac25df 100644 --- a/src/services/VoiceService.ts +++ b/src/services/VoiceService.ts @@ -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().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().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().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); }