diff --git a/.agents/workflows/kord_routine.md b/.agents/workflows/kord_routine.md index be27c50..c923dc6 100644 --- a/.agents/workflows/kord_routine.md +++ b/.agents/workflows/kord_routine.md @@ -7,7 +7,7 @@ description: Kord 프로젝트 개발 및 테스트 작업 루틴 ## 기본 원칙 (Work Rules) 1. **인프라 자율 사용**: 에이전트는 프로젝트에 설정된 Docker 기반 인프라(PostgreSQL, Redis 등)를 사용자의 추가 승인 없이 자유롭게 구동(`docker-compose up -d`) 및 활용할 수 있습니다. -2. **협력적 기획, 독립적 실행**: 기능 기획과 설계(Architecture, Schema 등)는 사용자와 함께 논의하고 피드백을 반영합니다. 기획이 승인된 후 진행되는 코드 작성, 에러 디버깅, 자체 테스트는 별도 질문 없이 에이전트가 주도적으로 끝마칩니다. +2. **협력적 기획, 독립적 실행**: 기능 기획과 설계(Architecture, Schema 등)는 사용자와 함께 논리적인 완결성을 갖출 때까지 충분히 논의합니다. 기획이 "완료 및 승인"된 후에는 후속 구현, 에러 디버깅, 자체 테스트를 추가적인 중간 확인 없이 에이전트가 주도를 가지고 끝마친 뒤 최종 결과를 보고합니다. ## 단계별 작업 루틴 @@ -16,7 +16,7 @@ description: Kord 프로젝트 개발 및 테스트 작업 루틴 - 변경 사항, 사용 스택, 아키텍처를 포함한 `implementation_plan.md`를 작성하거나 업데이트하여 사용자에게 검토 및 승인을 요청합니다. ### 2단계: 개발 및 구현 (Execution Phase) -- 설계가 승인되면 코딩을 시작합니다. +- 설계가 최종 승인되면 실제 코딩을 시작합니다. 이 단계부터는 사용자의 개입 없이 독립적으로 작업을 완수하는 것을 원칙으로 합니다. - 환경 변수 파싱, 로깅, 예외 처리를 철저히 포함하여 프로덕션 수준의 코드를 작성합니다. ### 3단계: 자체 구동 및 내부 테스트 (Internal Testing Phase) diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..ad8aadd --- /dev/null +++ b/.cursorrules @@ -0,0 +1,11 @@ +# Kord IDE Rules + +## Documentation Rules +- 프로젝트와 관련된 진행/완료 내역(Work done), 기술/기획적 결정(Decisions made), 트러블슈팅(Troubleshooting) 내역은 `/Docs/` 디렉토리에 카테고리별로 작성합니다. +- 새로운 문서를 추가하거나 수정하면 반드시 `Docs/index.md`에 색인(링크)을 추가합니다. +- 문서에는 `체인지로그(Changelog)`와 `본문(Body)` 구조를 포함합니다. +- 다른 문서에 있는 내용을 중복해서 적거나 허위 사실, "향후 추가 예정" 같은 Placeholder를 작성하지 마십시오. + +## Security Rules +- 비밀번호, API 키, 인증 토큰, URL에 포함된 인증 정보 등 민감한 정보는 소스 코드나 마크다운 문서 등 공개된 곳에 절대 기재하지 마십시오. +- 보안 설정값은 `.env` 등 Git 추적에서 제외된 파일을 사용하고 동적으로 로드하여 사용해야 합니다. diff --git a/.windsurfrules b/.windsurfrules new file mode 100644 index 0000000..ad8aadd --- /dev/null +++ b/.windsurfrules @@ -0,0 +1,11 @@ +# Kord IDE Rules + +## Documentation Rules +- 프로젝트와 관련된 진행/완료 내역(Work done), 기술/기획적 결정(Decisions made), 트러블슈팅(Troubleshooting) 내역은 `/Docs/` 디렉토리에 카테고리별로 작성합니다. +- 새로운 문서를 추가하거나 수정하면 반드시 `Docs/index.md`에 색인(링크)을 추가합니다. +- 문서에는 `체인지로그(Changelog)`와 `본문(Body)` 구조를 포함합니다. +- 다른 문서에 있는 내용을 중복해서 적거나 허위 사실, "향후 추가 예정" 같은 Placeholder를 작성하지 마십시오. + +## Security Rules +- 비밀번호, API 키, 인증 토큰, URL에 포함된 인증 정보 등 민감한 정보는 소스 코드나 마크다운 문서 등 공개된 곳에 절대 기재하지 마십시오. +- 보안 설정값은 `.env` 등 Git 추적에서 제외된 파일을 사용하고 동적으로 로드하여 사용해야 합니다. diff --git a/Docs/Decisions/subscription_tiers.md b/Docs/Decisions/subscription_tiers.md new file mode 100644 index 0000000..338aed3 --- /dev/null +++ b/Docs/Decisions/subscription_tiers.md @@ -0,0 +1,25 @@ +# 구독 티어 시스템 (Subscription Tiers) + +## 체인지로그 (Changelog) +- **2026-03-27**: 구독 티어제 체제 설계, 서버 내 생성용 채널 허용 개수 지정 정책화 + +## 본문 (Body) + +### 1. 개요 및 설계 의도 +이 문서는 사용자의 "구독 티어(Subscription Tier)"에 따른 시스템 제한을 설명합니다. `Kord`의 인프라는 모든 서버 자원을 제한 없이 사용하는 것을 방어하기 위해 디스코드 봇을 초대한 관리자의 티어 수준(Free ~ Premium)에 맞춰 리소스를 할당합니다. + +이는 음성 채널 분야뿐만 아니라 향후 개발될 로그 분석기, 커스텀 웹훅 등의 서비스에도 재사용할 수 있도록 `UserSubscription` 및 `GuildOwnership` 모델을 바탕으로 독립적으로 설계되었습니다. + +### 2. 티어 구조 명세 +티어를 보유하는 단위는 **사용자(User)**이며, 서버(Guild)는 소유자에게 귀속되어 티어를 상속받습니다. + +| 티어 레벨 (Tier) | 소유 가능한 적용 서버 수 한도 | 서버당 '생성용 채널' 개수 최대치 | +| :--- | :--- | :--- | +| **FREE (프리)** | 1인당 최대 1개 서버 | 서버당 최대 1개 | +| **STANDARD (스탠다드)** | 1인당 최대 3개 서버 | 서버당 최대 3개 | +| **PRO (프로)** | 1인당 최대 5개 서버 | 서버당 무제한 (`Unlimited`) | +| **PREMIUM (프리미엄)** | 1인당 최대 10개 서버 | 서버당 무제한 (`Unlimited`) | + +### 3. 검증 로직 가이드 +- **서버 소유권 연결 (`GuildOwnership`)**: 서버 관리자나 봇 초대자가 `/claim`을 통해 해당 서버의 티어 지갑을 본인과 연결합니다. +- **기능 제한 검사**: 자원 획득(예: `/voice-setup` 등) 커맨드가 실행될 때 `UserSubscription` 모델을 참조하여 현재 서버 내 카운트와 상한선을 대조한 뒤 통과 여부를 결정합니다. diff --git a/Docs/Features/temp_voice_channels.md b/Docs/Features/temp_voice_channels.md new file mode 100644 index 0000000..a8ae641 --- /dev/null +++ b/Docs/Features/temp_voice_channels.md @@ -0,0 +1,27 @@ +# Kord - 임시 음성 채널 제어 (Temp Voice Channels) + +## 체인지로그 (Changelog) +- **2026-03-27**: 임시 채널 자동 생성/삭제 로직, 티어 연동, 방장 관리 UI(Rename, Limit, Lock, Kick, Ban) 추가 + +## 본문 (Body) + +### 개요 +`Kord` 봇은 지정된 '생성용 채널(Generator Channel)'에 유저가 입장할 경우, 해당 유저만을 위한 '임시 음성 채널(Temporary Voice Channel)'을 동적으로 생성하고 관리합니다. 유저가 생성용 채널에 접속하면 봇은 데이터베이스의 티어와 기존 방 설정 내역을 검사하여 새로운 음성 채널을 만들고 권한을 연동해 줍니다. 반대로 누군가 방에서 퇴장할 때마다 조건에 맞춰 자동으로 삭제하여 서버 깔끔함을 유지합니다. + +### 주요 기능 사양 +1. **임시 채널 생성 원리**: + - 트리거 이벤트: `voiceStateUpdate` (유저가 `VoiceGenerator`에 지정된 채널 ID에 접속 시 확인) + - 중복 검증: 이미 본인의 임시 채널이 있는 유저는 새 채널을 생성하지 않고 기존 방으로 위치를 이동시킵니다. + - 이름 설정: `UserVoiceProfile` 정보를 불러와 `<유저 지정 이름>`으로, 없다면 `<유저명>'s Room` 기본 형식으로 생성합니다. + +2. **삭제 조건 (`DeleteCondition`)**: + - `EMPTY` (기본값): 채널 안에 남아있는 유저 인원수(`members.size`)가 `0`이 되는 순간 방 폭파. + - `OWNER_LEAVE`: 방장(소유자)이 방을 나가는 즉시 남은 인원수와 상관없이 삭제. + +3. **인터랙션(UI) 제어**: + 채널 생성 시 텍스트 채팅창을 통하여 방장만 조작할 수 있는 **버튼 컨트롤 패널**이 송신됩니다. + - 버튼 클릭 (InteractionCreate): 커스텀 ID(예: `vc_rename_`)와 상호작용. + - 조작 가능한 기능: 채널 이름 변경, 인원수 제한 변경, 외부 접속 여부 토글(Lock/Unlock), 유저 강퇴(Kick), 유저 차단 및 은신 (Ban/Hide). + +### 기술적 특이사항 (의사결정) +- 차단(Ban) 기능 모델링: 단순히 접속 거부가 아니라 "방 자체를 안 보이게" 조치할 수 있도록 특정 유저의 채널 오버라이드(PermissionOverwrite)에서 `ViewChannel`과 `Connect` 권한을 동시에 `false`로 주입합니다. diff --git a/Docs/Rules/security_guidelines.md b/Docs/Rules/security_guidelines.md new file mode 100644 index 0000000..54c24f9 --- /dev/null +++ b/Docs/Rules/security_guidelines.md @@ -0,0 +1,22 @@ +# 보안 개발 가이드라인 (Security Rules) + +## 체인지로그 (Changelog) +- **2026-03-27**: 민감 정보 하드코딩 금지 규칙 명문화 + +## 본문 (Body) + +`Kord` 개발, 테스트, 배포를 포함한 모든 프로젝트 활동 중 반드시 지켜야 하는 핵심 보안 정책입니다. + +### 민감 정보 하드코딩 금지 +코드, 마크다운 문서, 디렉터리 구성 파일(`.env.example`의 기본값 포함) 등 어떠한 경우에도 아래의 민감 정보를 소스 상에 **직접 문자열(String)로 기재하는 것을 절대 금지**합니다. + +#### 절대 금지 대상 예시: +- 디스코드 봇 토큰 (Discord Bot Tokens) +- 데이터베이스 비밀번호 및 접속 주소 (e.g. `postgresql://user:password@host/db`) +- Redis 비밀번호, API 인증 키(Keys), 비밀 암호 해시, 사내용 중요 자격증명 리스트 + +#### 올바른 해결 방법 +1. **환경 변수 파일 (`.env`) 사용**: + 모든 민감 정보는 루트 경로에 위치한 `.env` 파일 안에 적고, `.gitignore`를 통해 Git 추적에 포함되지 않도록 보장해야 합니다. +2. **동적 로드 매핑**: + TypeScript 환경에서는 환경 변수를 로드하고 타입 체킹을 해주는 별도의 모듈(예: `src/config/env.ts` 등)에서 `process.env.DISCORD_TOKEN` 과 같은 형태로 끌어다 사용하는 것이 권장됩니다. diff --git a/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md b/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md new file mode 100644 index 0000000..4f80d9f --- /dev/null +++ b/Docs/WorkDone/2026-03-27_Voice_Channels_Implementation.md @@ -0,0 +1,33 @@ +# 2026-03-27 작업 내역: 임시 음성 채널 기능 구현 + +## 체인지로그 (Changelog) +- **2026-03-27**: Kord 봇 음성 채널 제어 시스템 스펙 기획 및 전체 로직 구현 완료 + +## 본문 (Body) + +### 1. 🎯 주요 목표 (Objective) +사용자가 봇 서버의 특정한 '생성용 채널(Generator)'에 입장하면, 유저 본인 소유의 '임시 전용 채널'을 만들어 주고, 빈 방이 되었을 때 삭제하여 채널 환경을 깔끔하게 유지하는 자동화 시스템 및 티어 기반 한도 제어 시스템을 구축. + +### 2. 📝 진행 내역 (Work Done) +- **DB 스키마(Prisma) 설계 및 반영**: + - `SubscriptionTier` (FREE, STANDARD, PRO, PREMIUM) 추가. + - `UserSubscription`, `GuildOwnership` 모델 생성하여 관리자 티어에 따른 리소스 상속 체계 마련. + - `VoiceGenerator`, `TempVoiceChannel`, `UserVoiceProfile` 모델 반영. +- **백엔드 리소스 개발**: + - `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) + +### 3. 🤔 주요 의사결정 (Decisions Made) +- **차단(Ban) 동작 정의**: 특정 인터랙션에서 유저 밴 기능을 수행할 경우, 음성 채널 설정(Overwrite) API를 사용하여 해당 유저의 `Connect`, `ViewChannel` 권한을 모두 박탈함으로써 채널 텍스트 캐시 및 접속을 완전히 보이지 않게(투명화) 설계함. +- **티어 권한 소유 구조**: 서버 단위가 주체가 되는 것이 아니라, 봇을 초대한 '사용자 단위'가 주체가 되도록 `GuildOwnership`과 `UserSubscription`을 연결. 추후 다양한 서비스에 재활용 가능하도록 기반 구축. (`Docs/Decisions/subscription_tiers.md` 참고) + +### 4. 🛠️ 트러블슈팅 (Troubleshooting) +- **TypeScript 타입 캐스팅 관련 이슈**: + - *문제*: `interactionCreate`에서 `interaction.member.voice` 에 접근 시 `APIInteractionGuildMember` 타입과의 충돌로 컴파일(`yarn build`) 에러 발생. + - *해결*: `interaction.member as GuildMember` 명시적 타입 캐스팅을 통해 `voice.channel` 객체 보장 및 에러 해결 완료. +- **런타임 초기화 - 디스코드 권한(Intents) 충돌**: + - *문제*: 로컬 구동 테스트 중 `Error: Used disallowed intents` 발생. + - *원인*: `KordClient` 부팅 시 `GuildMembers`, `MessageContent`, `Presence` 세 가지 Privileged Gateway Intent를 요구하나 디스코드 온라인 설정상 비활성화되어 있음. + - *해결*: 개발 과정에선 소스 오류가 아님을 확인 후, 사용자에게 디스코드 포털에서의 활성화 작업 요청 조치. diff --git a/Docs/index.md b/Docs/index.md new file mode 100644 index 0000000..28ac04a --- /dev/null +++ b/Docs/index.md @@ -0,0 +1,16 @@ +# Kord Documentation Index + +이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다. + +## 정책 및 규칙 (Rules) +- [보안 가이드라인 (Security Rules)](Rules/security_guidelines.md) + +## 기능 명세 (Features) +- [임시 음성 채널 자동화 (Temp Voice Channels)](Features/temp_voice_channels.md) + +## 아키텍처 및 정책 결정 (Decisions) +- [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md) + +## 진행/완료 내역 (Work Done) +- [2026-03-27: 임시 음성 채널 기능 구현 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md) + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a441713..59f6b97 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,3 +24,61 @@ model InviteRole { @@unique([guildId, inviteCode]) } + +enum SubscriptionTier { + FREE + STANDARD + PRO + PREMIUM +} + +enum DeleteCondition { + OWNER_LEAVE + EMPTY +} + +model UserSubscription { + userId String @id + tier SubscriptionTier @default(FREE) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + guilds GuildOwnership[] +} + +model GuildOwnership { + guildId String @id + ownerId String + owner UserSubscription @relation(fields: [ownerId], references: [userId], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@index([ownerId]) +} + +model VoiceGenerator { + channelId String @id + guildId String + categoryId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([guildId]) +} + +model TempVoiceChannel { + channelId String @id + guildId String + ownerId String + deleteWhen DeleteCondition @default(EMPTY) + createdAt DateTime @default(now()) + + @@index([guildId]) + @@index([ownerId]) +} + +model UserVoiceProfile { + userId String @id + customName String? + userLimit Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/src/commands/voiceSetup.ts b/src/commands/voiceSetup.ts new file mode 100644 index 0000000..4382292 --- /dev/null +++ b/src/commands/voiceSetup.ts @@ -0,0 +1,54 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction, ChannelType } from 'discord.js'; +import { prisma } from '../database'; +import { logger } from '../utils/logger'; + +export default { + data: new SlashCommandBuilder() + .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) + ) + .addChannelOption(option => + option.setName('category') + .setDescription('(Optional) The category where temp channels will be created') + .setRequired(false) + .addChannelTypes(ChannelType.GuildCategory) + ), + + async execute(interaction: ChatInputCommandInteraction) { + const channel = interaction.options.getChannel('channel', true); + 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, + } + }); + + 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 + }); + } + }, +}; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts new file mode 100644 index 0000000..02d1e8f --- /dev/null +++ b/src/events/interactionCreate.ts @@ -0,0 +1,207 @@ +import { Events, Interaction, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ChannelType, VoiceChannel, PermissionFlagsBits, UserSelectMenuBuilder, GuildMember } from 'discord.js'; +import { KordClient } from '../client/KordClient'; +import { logger } from '../utils/logger'; +import { prisma } from '../database'; + +export default { + name: Events.InteractionCreate, + once: false, + async execute(interaction: Interaction, client: KordClient) { + if (interaction.isChatInputCommand()) { + const command = client.commands.get(interaction.commandName); + if (!command) return; + + try { + await command.execute(interaction); + } catch (error) { + logger.error(`Error executing ${interaction.commandName}`, error); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: 'There was an error while executing this command!', ephemeral: true }); + } else { + await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true }); + } + } + } + else if (interaction.isButton()) { + const customId = interaction.customId; + + if (customId.startsWith('vc_')) { + const parts = customId.split('_'); + const action = parts[1]; + const ownerId = parts[2]; + + if (interaction.user.id !== ownerId) { + return interaction.reply({ content: 'Only the channel owner can use these controls.', ephemeral: true }); + } + + const member = interaction.member as GuildMember; + const voiceChannel = member?.voice?.channel as VoiceChannel; + + if (!voiceChannel || interaction.channelId !== voiceChannel.id) { + const tempDb = await prisma.tempVoiceChannel.findUnique({ where: { channelId: voiceChannel?.id || '' }}); + if (!tempDb || tempDb.ownerId !== ownerId) { + return interaction.reply({ content: 'You must be in your active temporary voice channel to use this.', ephemeral: true }); + } + } + + try { + if (action === 'rename') { + const modal = new ModalBuilder() + .setCustomId(`modal_vc_rename_${ownerId}`) + .setTitle('Rename Voice Channel'); + + const nameInput = new TextInputBuilder() + .setCustomId('newName') + .setLabel('New Channel Name') + .setStyle(TextInputStyle.Short) + .setValue(voiceChannel.name) + .setRequired(true) + .setMaxLength(100); + + modal.addComponents(new ActionRowBuilder().addComponents(nameInput)); + await interaction.showModal(modal); + } + else if (action === 'limit') { + const modal = new ModalBuilder() + .setCustomId(`modal_vc_limit_${ownerId}`) + .setTitle('Set User Limit'); + + const limitInput = new TextInputBuilder() + .setCustomId('limit') + .setLabel('User Limit (0 for unlimited, 1-99)') + .setStyle(TextInputStyle.Short) + .setValue(voiceChannel.userLimit.toString()) + .setRequired(true) + .setMaxLength(2); + + modal.addComponents(new ActionRowBuilder().addComponents(limitInput)); + await interaction.showModal(modal); + } + else if (action === 'lock') { + const everyonePerms = voiceChannel.permissionOverwrites.cache.get(voiceChannel.guild.id); + const isLocked = everyonePerms?.deny.has(PermissionFlagsBits.Connect); + if (isLocked) { + await voiceChannel.permissionOverwrites.edit(voiceChannel.guild.id, { Connect: null }); + await interaction.reply({ content: 'Channel Unlocked! Anyone can join now.', ephemeral: true }); + } else { + await voiceChannel.permissionOverwrites.edit(voiceChannel.guild.id, { Connect: false }); + await interaction.reply({ content: 'Channel Locked! Only you and invited members can join.', ephemeral: true }); + } + } + else if (action === 'kick') { + const select = new UserSelectMenuBuilder() + .setCustomId(`select_vc_kick_${ownerId}`) + .setPlaceholder('Select a user to kick') + .setMinValues(1) + .setMaxValues(1); + await interaction.reply({ components: [new ActionRowBuilder().addComponents(select)], ephemeral: true }); + } + else if (action === 'ban') { + const select = new UserSelectMenuBuilder() + .setCustomId(`select_vc_ban_${ownerId}`) + .setPlaceholder('Select a user to ban/hide') + .setMinValues(1) + .setMaxValues(1); + await interaction.reply({ content: 'Banning will make the channel invisible to them.', 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 }); + } + } + } + else if (interaction.isModalSubmit()) { + const customId = interaction.customId; + if (customId.startsWith('modal_vc_')) { + const parts = customId.split('_'); + const action = parts[2]; + const ownerId = parts[3]; + + if (interaction.user.id !== ownerId) return interaction.reply({ content: 'Unauthorized', ephemeral: true }); + + const member = interaction.member as GuildMember; + const voiceChannel = member?.voice?.channel as VoiceChannel; + if (!voiceChannel) return interaction.reply({ content: 'Join your voice channel first.', ephemeral: true }); + + try { + if (action === 'rename') { + const newName = interaction.fields.getTextInputValue('newName'); + await voiceChannel.setName(newName); + + await prisma.userVoiceProfile.upsert({ + where: { userId: ownerId }, + update: { customName: newName }, + create: { userId: ownerId, customName: newName } + }); + + await interaction.reply({ content: `Channel renamed to **${newName}**!`, ephemeral: true }); + } + else if (action === 'limit') { + const limitStr = interaction.fields.getTextInputValue('limit'); + const limit = parseInt(limitStr); + if (isNaN(limit) || limit < 0 || limit > 99) { + return interaction.reply({ content: 'Invalid limit number.', ephemeral: true }); + } + await voiceChannel.setUserLimit(limit); + + await prisma.userVoiceProfile.upsert({ + where: { userId: ownerId }, + update: { userLimit: limit }, + create: { userId: ownerId, userLimit: limit } + }); + + await interaction.reply({ content: `Channel limit set to **${limit === 0 ? 'Unlimited' : limit}**!`, ephemeral: true }); + } + } catch (e) { + logger.error('Modal error', e); + await interaction.reply({ content: 'Failed to process changes. Is rate-limited by Discord?', ephemeral: true }); + } + } + } + else if (interaction.isUserSelectMenu()) { + const customId = interaction.customId; + if (customId.startsWith('select_vc_')) { + const parts = customId.split('_'); + const action = parts[2]; + const ownerId = parts[3]; + + if (interaction.user.id !== ownerId) return interaction.reply({ content: 'Unauthorized', ephemeral: true }); + + const member = interaction.member as GuildMember; + const voiceChannel = member?.voice?.channel as VoiceChannel; + if (!voiceChannel) return interaction.reply({ content: 'Join your voice channel first.', ephemeral: true }); + + const targetUserId = interaction.values[0]; + if (targetUserId === ownerId) return interaction.reply({ content: 'You cannot target yourself.', ephemeral: true }); + + try { + if (action === 'kick') { + const targetMember = await interaction.guild?.members.fetch(targetUserId); + if (targetMember && targetMember.voice.channelId === voiceChannel.id) { + await targetMember.voice.disconnect('Kicked by channel owner'); + await interaction.reply({ content: `Kicked <@${targetUserId}> from the channel.`, ephemeral: true }); + } else { + await interaction.reply({ content: `User is not in the channel.`, ephemeral: true }); + } + } + else if (action === 'ban') { + await voiceChannel.permissionOverwrites.edit(targetUserId, { + ViewChannel: false, + Connect: false + }); + + const targetMember = await interaction.guild?.members.fetch(targetUserId); + if (targetMember && targetMember.voice.channelId === voiceChannel.id) { + await targetMember.voice.disconnect('Banned by channel owner'); + } + + await interaction.reply({ content: `Banned and hidden channel from <@${targetUserId}>.`, ephemeral: true }); + } + } catch (e) { + logger.error('Select menu error', e); + await interaction.reply({ content: 'Action failed.', ephemeral: true }); + } + } + } + }, +}; diff --git a/src/events/ready.ts b/src/events/ready.ts index 1f8f1a0..9374ca5 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,12 +1,21 @@ -import { Events, Client } from 'discord.js'; +import { Events } from 'discord.js'; +import { KordClient } from '../client/KordClient'; import { logger } from '../utils/logger'; import { InviteService } from '../services/InviteService'; export default { name: Events.ClientReady, once: true, - async execute(client: Client) { + async execute(client: KordClient) { logger.info(`Ready! Logged in as ${client.user?.tag}`); await InviteService.cacheAllInvites(client); + + try { + const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON()); + await client.application?.commands.set(commandsData); + logger.info(`Successfully registered ${commandsData.length} global application commands.`); + } catch (e) { + logger.error('Failed to register global commands', e); + } }, }; diff --git a/src/services/VoiceService.ts b/src/services/VoiceService.ts index bf88250..34500dd 100644 --- a/src/services/VoiceService.ts +++ b/src/services/VoiceService.ts @@ -1,91 +1,149 @@ -import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel } from 'discord.js'; -import { env } from '../config/env'; +import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { prisma } from '../database'; import { logger } from '../utils/logger'; -// Set to track IDs of dynamic channels -const dynamicChannels = new Set(); - export class VoiceService { public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) { const member = newState.member; if (!member) return; - // Joined a voice channel if (!oldState.channelId && newState.channelId) { await this.handleJoin(newState); - } - // Switched voice channels - else if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) { + } else if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) { await this.handleLeave(oldState); await this.handleJoin(newState); - } - // Left a voice channel - else if (oldState.channelId && !newState.channelId) { + } else if (oldState.channelId && !newState.channelId) { await this.handleLeave(oldState); } } private static async handleJoin(state: VoiceState) { - if (state.channelId === env.VOICE_WAITING_ROOM_ID) { + const guild = state.guild; + const member = state.member!; + const channelId = state.channelId!; + + const generator = await prisma.voiceGenerator.findUnique({ + where: { channelId } + }); + + if (!generator) return; + + const botMember = guild.members.me; + if (!botMember?.permissions.has(PermissionFlagsBits.ManageChannels)) { + logger.warn(`Bot lacks ManageChannels in guild ${guild.id}`); + return; + } + + const existingTemp = await prisma.tempVoiceChannel.findFirst({ + where: { guildId: guild.id, ownerId: member.id } + }); + + if (existingTemp) { try { - const guild = state.guild; - const member = state.member!; - - // Ensure bot has permission before creating channel - const botMember = guild.members.me; - if (!botMember?.permissions.has(PermissionFlagsBits.ManageChannels)) { - logger.warn(`Bot lacks ManageChannels permission in guild ${guild.id}`); - return; - } - - const newChannel = await guild.channels.create({ - name: `${member.user.username}'s Room`, - type: ChannelType.GuildVoice, - parent: env.VOICE_CATEGORY_ID || state.channel?.parentId || undefined, - permissionOverwrites: [ - { - id: guild.roles.everyone.id, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], - }, - { - id: member.id, - allow: [ - PermissionFlagsBits.ManageChannels, - PermissionFlagsBits.ManageRoles, - PermissionFlagsBits.MuteMembers, - PermissionFlagsBits.DeafenMembers, - PermissionFlagsBits.MoveMembers, - ], - }, - ], - }); - - dynamicChannels.add(newChannel.id); - - // Move user smoothly to their new room - await member.voice.setChannel(newChannel); - logger.info(`VoiceMaster: Created channel ${newChannel.name} for ${member.user.tag}`); - } catch (error) { - logger.error(`VoiceMaster Join Error:`, error); + await member.voice.setChannel(existingTemp.channelId); + } catch (e) { + logger.error(`Could not move user to existing voice channel`, e); } + return; + } + + const profile = await prisma.userVoiceProfile.findUnique({ where: { userId: member.id }}); + const channelName = profile?.customName || `${member.user.username}'s Room`; + const userLimit = profile?.userLimit || 0; + + 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, + ], + }, + ], + }); + + await prisma.tempVoiceChannel.create({ + data: { + channelId: newChannel.id, + guildId: guild.id, + ownerId: member.id, + } + }); + + await member.voice.setChannel(newChannel); + logger.info(`VoiceService: Created ${newChannel.name} for ${member.user.tag}`); + + await this.sendControlPanel(newChannel, member.id); + } catch (error) { + logger.error(`VoiceService Join Error:`, error); } } private static async handleLeave(state: VoiceState) { - const channelId = state.channelId; - if (!channelId) return; + const channelId = state.channelId!; + const member = state.member!; - if (dynamicChannels.has(channelId)) { - const channel = state.channel as VoiceChannel; - if (channel && channel.members.size === 0) { - try { - await channel.delete(); - dynamicChannels.delete(channelId); - logger.info(`VoiceMaster: Deleted empty dynamic channel: ${channel.name}`); - } catch (error) { - logger.error(`VoiceMaster Leave Error:`, error); - } + const tempChannel = await prisma.tempVoiceChannel.findUnique({ + where: { channelId } + }); + + if (!tempChannel) return; + + const channel = state.channel as VoiceChannel; + if (!channel) { + // Channel might already be deleted by other means + await prisma.tempVoiceChannel.delete({ where: { channelId } }).catch(() => {}); + return; + } + + let shouldDelete = false; + if (tempChannel.deleteWhen === 'EMPTY') { + shouldDelete = channel.members.size === 0; + } else if (tempChannel.deleteWhen === 'OWNER_LEAVE') { + shouldDelete = tempChannel.ownerId === member.id; + } + + if (shouldDelete) { + try { + await channel.delete(); + 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(() => {}); } } } + + private static async sendControlPanel(channel: VoiceChannel, ownerId: string) { + const row = 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) + ); + + try { + await channel.send({ + content: `Welcome to your channel! Only the owner (<@${ownerId}>) can use these controls.`, + components: [row] + }); + } catch (e) { + logger.error('Failed to send control panel UI', e); + } + } } diff --git a/tsconfig.json b/tsconfig.json index bf44a29..986ada0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,8 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "baseUrl": ".", + "ignoreDeprecations": "6.0", + "types": ["jest", "node"], "paths": { "@/*": ["src/*"] }