feat: Implement dynamic voice channels with new database models, service logic, interaction handling, and supporting documentation.
This commit is contained in:
parent
b8c18cfefe
commit
f5c8bdb85b
|
|
@ -7,7 +7,7 @@ description: Kord 프로젝트 개발 및 테스트 작업 루틴
|
||||||
|
|
||||||
## 기본 원칙 (Work Rules)
|
## 기본 원칙 (Work Rules)
|
||||||
1. **인프라 자율 사용**: 에이전트는 프로젝트에 설정된 Docker 기반 인프라(PostgreSQL, Redis 등)를 사용자의 추가 승인 없이 자유롭게 구동(`docker-compose up -d`) 및 활용할 수 있습니다.
|
1. **인프라 자율 사용**: 에이전트는 프로젝트에 설정된 Docker 기반 인프라(PostgreSQL, Redis 등)를 사용자의 추가 승인 없이 자유롭게 구동(`docker-compose up -d`) 및 활용할 수 있습니다.
|
||||||
2. **협력적 기획, 독립적 실행**: 기능 기획과 설계(Architecture, Schema 등)는 사용자와 함께 논의하고 피드백을 반영합니다. 기획이 승인된 후 진행되는 코드 작성, 에러 디버깅, 자체 테스트는 별도 질문 없이 에이전트가 주도적으로 끝마칩니다.
|
2. **협력적 기획, 독립적 실행**: 기능 기획과 설계(Architecture, Schema 등)는 사용자와 함께 논리적인 완결성을 갖출 때까지 충분히 논의합니다. 기획이 "완료 및 승인"된 후에는 후속 구현, 에러 디버깅, 자체 테스트를 추가적인 중간 확인 없이 에이전트가 주도를 가지고 끝마친 뒤 최종 결과를 보고합니다.
|
||||||
|
|
||||||
## 단계별 작업 루틴
|
## 단계별 작업 루틴
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ description: Kord 프로젝트 개발 및 테스트 작업 루틴
|
||||||
- 변경 사항, 사용 스택, 아키텍처를 포함한 `implementation_plan.md`를 작성하거나 업데이트하여 사용자에게 검토 및 승인을 요청합니다.
|
- 변경 사항, 사용 스택, 아키텍처를 포함한 `implementation_plan.md`를 작성하거나 업데이트하여 사용자에게 검토 및 승인을 요청합니다.
|
||||||
|
|
||||||
### 2단계: 개발 및 구현 (Execution Phase)
|
### 2단계: 개발 및 구현 (Execution Phase)
|
||||||
- 설계가 승인되면 코딩을 시작합니다.
|
- 설계가 최종 승인되면 실제 코딩을 시작합니다. 이 단계부터는 사용자의 개입 없이 독립적으로 작업을 완수하는 것을 원칙으로 합니다.
|
||||||
- 환경 변수 파싱, 로깅, 예외 처리를 철저히 포함하여 프로덕션 수준의 코드를 작성합니다.
|
- 환경 변수 파싱, 로깅, 예외 처리를 철저히 포함하여 프로덕션 수준의 코드를 작성합니다.
|
||||||
|
|
||||||
### 3단계: 자체 구동 및 내부 테스트 (Internal Testing Phase)
|
### 3단계: 자체 구동 및 내부 테스트 (Internal Testing Phase)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Kord IDE Rules
|
||||||
|
|
||||||
|
## Documentation Rules
|
||||||
|
- 프로젝트와 관련된 진행/완료 내역(Work done), 기술/기획적 결정(Decisions made), 트러블슈팅(Troubleshooting) 내역은 `<PROJECT_ROOT>/Docs/` 디렉토리에 카테고리별로 작성합니다.
|
||||||
|
- 새로운 문서를 추가하거나 수정하면 반드시 `Docs/index.md`에 색인(링크)을 추가합니다.
|
||||||
|
- 문서에는 `체인지로그(Changelog)`와 `본문(Body)` 구조를 포함합니다.
|
||||||
|
- 다른 문서에 있는 내용을 중복해서 적거나 허위 사실, "향후 추가 예정" 같은 Placeholder를 작성하지 마십시오.
|
||||||
|
|
||||||
|
## Security Rules
|
||||||
|
- 비밀번호, API 키, 인증 토큰, URL에 포함된 인증 정보 등 민감한 정보는 소스 코드나 마크다운 문서 등 공개된 곳에 절대 기재하지 마십시오.
|
||||||
|
- 보안 설정값은 `.env` 등 Git 추적에서 제외된 파일을 사용하고 동적으로 로드하여 사용해야 합니다.
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Kord IDE Rules
|
||||||
|
|
||||||
|
## Documentation Rules
|
||||||
|
- 프로젝트와 관련된 진행/완료 내역(Work done), 기술/기획적 결정(Decisions made), 트러블슈팅(Troubleshooting) 내역은 `<PROJECT_ROOT>/Docs/` 디렉토리에 카테고리별로 작성합니다.
|
||||||
|
- 새로운 문서를 추가하거나 수정하면 반드시 `Docs/index.md`에 색인(링크)을 추가합니다.
|
||||||
|
- 문서에는 `체인지로그(Changelog)`와 `본문(Body)` 구조를 포함합니다.
|
||||||
|
- 다른 문서에 있는 내용을 중복해서 적거나 허위 사실, "향후 추가 예정" 같은 Placeholder를 작성하지 마십시오.
|
||||||
|
|
||||||
|
## Security Rules
|
||||||
|
- 비밀번호, API 키, 인증 토큰, URL에 포함된 인증 정보 등 민감한 정보는 소스 코드나 마크다운 문서 등 공개된 곳에 절대 기재하지 마십시오.
|
||||||
|
- 보안 설정값은 `.env` 등 Git 추적에서 제외된 파일을 사용하고 동적으로 로드하여 사용해야 합니다.
|
||||||
|
|
@ -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` 모델을 참조하여 현재 서버 내 카운트와 상한선을 대조한 뒤 통과 여부를 결정합니다.
|
||||||
|
|
@ -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_<OwnerId>`)와 상호작용.
|
||||||
|
- 조작 가능한 기능: 채널 이름 변경, 인원수 제한 변경, 외부 접속 여부 토글(Lock/Unlock), 유저 강퇴(Kick), 유저 차단 및 은신 (Ban/Hide).
|
||||||
|
|
||||||
|
### 기술적 특이사항 (의사결정)
|
||||||
|
- 차단(Ban) 기능 모델링: 단순히 접속 거부가 아니라 "방 자체를 안 보이게" 조치할 수 있도록 특정 유저의 채널 오버라이드(PermissionOverwrite)에서 `ViewChannel`과 `Connect` 권한을 동시에 `false`로 주입합니다.
|
||||||
|
|
@ -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` 과 같은 형태로 끌어다 사용하는 것이 권장됩니다.
|
||||||
|
|
@ -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를 요구하나 디스코드 온라인 설정상 비활성화되어 있음.
|
||||||
|
- *해결*: 개발 과정에선 소스 오류가 아님을 확인 후, 사용자에게 디스코드 포털에서의 활성화 작업 요청 조치.
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
@ -24,3 +24,61 @@ model InviteRole {
|
||||||
|
|
||||||
@@unique([guildId, inviteCode])
|
@@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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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<TextInputBuilder>().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<TextInputBuilder>().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<UserSelectMenuBuilder>().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<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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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 { logger } from '../utils/logger';
|
||||||
import { InviteService } from '../services/InviteService';
|
import { InviteService } from '../services/InviteService';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.ClientReady,
|
name: Events.ClientReady,
|
||||||
once: true,
|
once: true,
|
||||||
async execute(client: Client) {
|
async execute(client: KordClient) {
|
||||||
logger.info(`Ready! Logged in as ${client.user?.tag}`);
|
logger.info(`Ready! Logged in as ${client.user?.tag}`);
|
||||||
await InviteService.cacheAllInvites(client);
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,149 @@
|
||||||
import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel } from 'discord.js';
|
import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
|
||||||
import { env } from '../config/env';
|
import { prisma } from '../database';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
// Set to track IDs of dynamic channels
|
|
||||||
const dynamicChannels = new Set<string>();
|
|
||||||
|
|
||||||
export class VoiceService {
|
export class VoiceService {
|
||||||
public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
|
public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
|
||||||
const member = newState.member;
|
const member = newState.member;
|
||||||
if (!member) return;
|
if (!member) return;
|
||||||
|
|
||||||
// Joined a voice channel
|
|
||||||
if (!oldState.channelId && newState.channelId) {
|
if (!oldState.channelId && newState.channelId) {
|
||||||
await this.handleJoin(newState);
|
await this.handleJoin(newState);
|
||||||
}
|
} else if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
|
||||||
// Switched voice channels
|
|
||||||
else if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
|
|
||||||
await this.handleLeave(oldState);
|
await this.handleLeave(oldState);
|
||||||
await this.handleJoin(newState);
|
await this.handleJoin(newState);
|
||||||
}
|
} else if (oldState.channelId && !newState.channelId) {
|
||||||
// Left a voice channel
|
|
||||||
else if (oldState.channelId && !newState.channelId) {
|
|
||||||
await this.handleLeave(oldState);
|
await this.handleLeave(oldState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async handleJoin(state: VoiceState) {
|
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 {
|
try {
|
||||||
const guild = state.guild;
|
await member.voice.setChannel(existingTemp.channelId);
|
||||||
const member = state.member!;
|
} catch (e) {
|
||||||
|
logger.error(`Could not move user to existing voice channel`, e);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
private static async handleLeave(state: VoiceState) {
|
||||||
const channelId = state.channelId;
|
const channelId = state.channelId!;
|
||||||
if (!channelId) return;
|
const member = state.member!;
|
||||||
|
|
||||||
if (dynamicChannels.has(channelId)) {
|
const tempChannel = await prisma.tempVoiceChannel.findUnique({
|
||||||
const channel = state.channel as VoiceChannel;
|
where: { channelId }
|
||||||
if (channel && channel.members.size === 0) {
|
});
|
||||||
try {
|
|
||||||
await channel.delete();
|
if (!tempChannel) return;
|
||||||
dynamicChannels.delete(channelId);
|
|
||||||
logger.info(`VoiceMaster: Deleted empty dynamic channel: ${channel.name}`);
|
const channel = state.channel as VoiceChannel;
|
||||||
} catch (error) {
|
if (!channel) {
|
||||||
logger.error(`VoiceMaster Leave Error:`, error);
|
// 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<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),
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"types": ["jest", "node"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue