Compare commits
No commits in common. "7dd437c373d47716e73a63482f8eda185d7bddba" and "234a0e96fed9e4f717e776e9396cd328c7300644" have entirely different histories.
7dd437c373
...
234a0e96fe
|
|
@ -1,41 +1,30 @@
|
||||||
---
|
---
|
||||||
trigger: model_decision
|
description: Documentation Workflow
|
||||||
description: documentation
|
|
||||||
---
|
---
|
||||||
|
|
||||||
이 워크플로우는 코드를 작성하거나 수정하기 전, 중, 후에 반드시 따라야 하는 문서화 규칙을 정의합니다. (Dcos는 올바른 Docs 디렉토리로 간주하여 작성되었습니다.)
|
이 워크플로우는 코드를 작성하거나 수정하기 전, 중, 후에 반드시 따라야 하는 문서화 규칙을 정의합니다. (Dcos는 올바른 Docs 디렉토리로 간주하여 작성되었습니다.)
|
||||||
|
|
||||||
## 핵심 원칙 (Core Principles)
|
## 핵심 원칙 (Core Principles)
|
||||||
|
|
||||||
1. **문서화 위치 (Location)**: 모든 문서는 반드시 `<PROJECT_ROOT>/Docs/` 디렉토리 내에 작성되어야 합니다.
|
1. **문서화 위치 (Location)**: 모든 문서는 반드시 `<PROJECT_ROOT>/Docs/` 디렉토리 내에 작성되어야 합니다.
|
||||||
2. **사전 탐색 (Search First)**: 작업을 시작하기 전에 `Docs/` 내의 관련 문서 유무를 먼저 탐색하고, 명시된 규칙/내용을 확인한 뒤 작업에 적용해야 합니다.
|
2. **사전 탐색 (Search First)**: 작업을 시작하기 전에 `Docs/` 내의 관련 문서 유무를 먼저 탐색하고, 명시된 규칙/내용을 확인한 뒤 작업에 적용해야 합니다.
|
||||||
|
|
||||||
## 기록 대상 (What to Document)
|
## 기록 대상 (What to Document)
|
||||||
|
|
||||||
프로젝트와 관련된 다음의 내용들은 반드시 문서화해야 합니다:
|
프로젝트와 관련된 다음의 내용들은 반드시 문서화해야 합니다:
|
||||||
|
|
||||||
- 진행 및 완료한 작업 내역 (Work done)
|
- 진행 및 완료한 작업 내역 (Work done)
|
||||||
- 중요한 기술적/기획적 의사 결정 사항 (Decisions made)
|
- 중요한 기술적/기획적 의사 결정 사항 (Decisions made)
|
||||||
- 문제 해결 과정 및 트러블 슈팅 내역 (Troubleshooting)
|
- 문제 해결 과정 및 트러블 슈팅 내역 (Troubleshooting)
|
||||||
|
|
||||||
## 색인 규칙 (Indexing Rules)
|
## 색인 규칙 (Indexing Rules)
|
||||||
|
|
||||||
문서의 저장과 탐색을 원활하게 하기 위해 다음 규칙을 따릅니다:
|
문서의 저장과 탐색을 원활하게 하기 위해 다음 규칙을 따릅니다:
|
||||||
|
|
||||||
1. **카테고리별 디렉토리 (Directory by Category)**: 문서는 카테고리별로 서브 디렉토리를 구성하여(예: `Docs/Troubleshooting/`, `Docs/Decisions/` 등) 저장합니다.
|
1. **카테고리별 디렉토리 (Directory by Category)**: 문서는 카테고리별로 서브 디렉토리를 구성하여(예: `Docs/Troubleshooting/`, `Docs/Decisions/` 등) 저장합니다.
|
||||||
2. **index.md 색인화 (Indexing)**: 새로운 문서를 추가하거나 기존 문서를 변경하면, 반드시 `Docs/index.md` 파일에 해당 문서를 색인(링크 추가)하여 탐색을 용이하게 해야 합니다.
|
2. **index.md 색인화 (Indexing)**: 새로운 문서를 추가하거나 기존 문서를 변경하면, 반드시 `Docs/index.md` 파일에 해당 문서를 색인(링크 추가)하여 탐색을 용이하게 해야 합니다.
|
||||||
|
|
||||||
## 문서 기본 템플릿 (Basic Template)
|
## 문서 기본 템플릿 (Basic Template)
|
||||||
|
|
||||||
모든 문서는 다음의 기본 구조를 포함해야 합니다.
|
모든 문서는 다음의 기본 구조를 포함해야 합니다.
|
||||||
|
|
||||||
- **체인지로그 (Changelog)**: 이 문서의 수정 내역 또는 관련 코드의 변경 이력 요약
|
- **체인지로그 (Changelog)**: 이 문서의 수정 내역 또는 관련 코드의 변경 이력 요약
|
||||||
- **본문 (Body)**: 상세 가이드, 문제 해결 과정, 의사 결정 배경 등 본 내용
|
- **본문 (Body)**: 상세 가이드, 문제 해결 과정, 의사 결정 배경 등 본 내용
|
||||||
|
|
||||||
## 금지 규칙 (Forbidden Rules)
|
## 금지 규칙 (Forbidden Rules)
|
||||||
|
|
||||||
문서를 작성할 때 **절대 금지**해야 하는 항목입니다:
|
문서를 작성할 때 **절대 금지**해야 하는 항목입니다:
|
||||||
|
|
||||||
- **중복 내용 (Duplicated Content)**: 다른 문서나 시스템에 이미 존재하는 내용을 반복해서 적지 마십시오.
|
- **중복 내용 (Duplicated Content)**: 다른 문서나 시스템에 이미 존재하는 내용을 반복해서 적지 마십시오.
|
||||||
- **없는 내용 (Fabricated Content)**: 근거가 없거나 실제로 이루어지지 않은 내용을 작성하지 마십시오.
|
- **없는 내용 (Fabricated Content)**: 근거가 없거나 실제로 이루어지지 않은 내용을 작성하지 마십시오.
|
||||||
- **향후 추가할 내용 (Placeholder Content)**: 아직 정해지지 않았거나 미완성인 내용을 "향후 추가 예정"이라고 적어두는 것을 금합니다. (이는 결국 의미 없는 껍데기 문서가 되므로 작성을 지양합니다.)
|
- **향후 추가할 내용 (Placeholder Content)**: 아직 정해지지 않았거나 미완성인 내용을 "향후 추가 예정"이라고 적어두는 것을 금합니다. (이는 결국 의미 없는 껍데기 문서가 되므로 작성을 지양합니다.)
|
||||||
|
|
@ -1,45 +1,36 @@
|
||||||
---
|
---
|
||||||
trigger: model_decision
|
description: Kord 프로젝트 개발 및 테스트 작업 루틴
|
||||||
description: work routine
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Kord Discord Bot Development Routine & Rules
|
# Kord Discord Bot Development Routine & Rules
|
||||||
|
|
||||||
이 워크플로우는 Kord 프로젝트의 기능 개발 및 테스트 표준 절차를 정의합니다. Kord와 관련된 모든 작업 지시를 받을 때 다음 규칙과 절차를 반드시 따르십시오.
|
이 워크플로우는 Kord 프로젝트의 기능 개발 및 테스트 표준 절차를 정의합니다. 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 등)는 사용자와 함께 논리적인 완결성을 갖출 때까지 충분히 논의합니다. 기획이 "완료 및 승인"된 후에는 후속 구현, 에러 디버깅, 자체 테스트를 추가적인 중간 확인 없이 에이전트가 주도를 가지고 끝마친 뒤 최종 결과를 보고합니다.
|
||||||
|
|
||||||
## 단계별 작업 루틴
|
## 단계별 작업 루틴
|
||||||
|
|
||||||
### 1단계: 기획 및 설계 (Planning Phase)
|
### 1단계: 기획 및 설계 (Planning Phase)
|
||||||
|
|
||||||
- 사용자가 새로운 기능이나 수정 사항을 요청하면, 필요한 스펙 및 예외 사항을 꼼꼼히 확인합니다.
|
- 사용자가 새로운 기능이나 수정 사항을 요청하면, 필요한 스펙 및 예외 사항을 꼼꼼히 확인합니다.
|
||||||
- 변경 사항, 사용 스택, 아키텍처를 포함한 `implementation_plan.md`를 작성하거나 업데이트하여 사용자에게 검토 및 승인을 요청합니다.
|
- 변경 사항, 사용 스택, 아키텍처를 포함한 `implementation_plan.md`를 작성하거나 업데이트하여 사용자에게 검토 및 승인을 요청합니다.
|
||||||
|
|
||||||
### 2단계: 개발 및 구현 (Execution Phase)
|
### 2단계: 개발 및 구현 (Execution Phase)
|
||||||
|
|
||||||
- 설계가 최종 승인되면 실제 코딩을 시작합니다. 이 단계부터는 사용자의 개입 없이 독립적으로 작업을 완수하는 것을 원칙으로 합니다.
|
- 설계가 최종 승인되면 실제 코딩을 시작합니다. 이 단계부터는 사용자의 개입 없이 독립적으로 작업을 완수하는 것을 원칙으로 합니다.
|
||||||
- 환경 변수 파싱, 로깅, 예외 처리를 철저히 포함하여 프로덕션 수준의 코드를 작성합니다.
|
- 환경 변수 파싱, 로깅, 예외 처리를 철저히 포함하여 프로덕션 수준의 코드를 작성합니다.
|
||||||
|
|
||||||
### 3단계: 자체 구동 및 내부 테스트 (Internal Testing Phase)
|
### 3단계: 자체 구동 및 내부 테스트 (Internal Testing Phase)
|
||||||
|
|
||||||
- 사용자가 최종 테스트를 하기 전에 **에이전트 스스로 봇이 모순이나 심각한 에러 없이 구동 가능한지 확인**해야 합니다.
|
- 사용자가 최종 테스트를 하기 전에 **에이전트 스스로 봇이 모순이나 심각한 에러 없이 구동 가능한지 확인**해야 합니다.
|
||||||
- 모든 시뮬레이션 및 테스트 구동 시 반드시 `yarn` 패키지 매니저를 사용합니다.
|
|
||||||
- 동시 실행 시 충돌을 방지하기 위해, 테스트 구동 시에는 반드시 **임의의 고유한 `INSTANCE_ID` 환경 변수를 할당**(`INSTANCE_ID=agent-test-xxxx yarn run dev`)하여 기존 인스턴스와 락(Lock)이나 로그가 겹치지 않게 합니다.
|
|
||||||
- 필요하다면 단위 테스트(`yarn test`)를 실행합니다.
|
- 필요하다면 단위 테스트(`yarn test`)를 실행합니다.
|
||||||
|
- 인프라(DB/Cache)를 연결하고 로컬에서 봇을 시험 가동하여 TypeScript 컴파일 에러나 런타임 초기화 에러가 없는지 완벽하게 점검합니다.
|
||||||
- 오류 발생 시 자체적으로 판단하고 디버깅하여 해결합니다.
|
- 오류 발생 시 자체적으로 판단하고 디버깅하여 해결합니다.
|
||||||
|
|
||||||
### 4단계: 사용자 최종 테스트 지원 (Final Manual Testing)
|
### 4단계: 사용자 최종 테스트 지원 (Final Manual Testing)
|
||||||
|
|
||||||
- 내/외부 테스트를 거쳐 정상 구동이 확정된 버전을 사용자에게 보고하기 전에, 다음의 5단계를 수행합니다.
|
- 내/외부 테스트를 거쳐 정상 구동이 확정된 버전을 사용자에게 보고하기 전에, 다음의 5단계를 수행합니다.
|
||||||
- 사용자는 실질적으로 자신의 디스코드 서버에 봇을 초대하여 인게임/인앱 시나리오를 수동 테스트합니다.
|
- 사용자는 실질적으로 자신의 디스코드 서버에 봇을 초대하여 인게임/인앱 시나리오를 수동 테스트합니다.
|
||||||
- **사후 검토**: 이 과정에서 사용자가 버그나 오류를 보고할 경우, 에이전트는 로컬 터미널의 로그(Log)나 DB의 상태를 검토(조회 명령어 사용 등)하여 무엇이 문제였는지 면밀히 파악하고 수정안을 제시해야 합니다.
|
- **사후 검토**: 이 과정에서 사용자가 버그나 오류를 보고할 경우, 에이전트는 로컬 터미널의 로그(Log)나 DB의 상태를 검토(조회 명령어 사용 등)하여 무엇이 문제였는지 면밀히 파악하고 수정안을 제시해야 합니다.
|
||||||
|
|
||||||
### 5단계: 자동 문서화 및 인덱싱 (Documentation Phase)
|
### 5단계: 자동 문서화 및 인덱싱 (Documentation Phase)
|
||||||
|
|
||||||
- 3단계 구현 및 테스트가 성공적으로 완료되면, 사용자에게 최종 보고하기 **전에 반드시 먼저** `<PROJECT_ROOT>/Docs/` 디렉토리에 작업 완료(Work done), 트러블슈팅(Troubleshooting), 의사 결정(Decisions made) 내역을 문서화해야 합니다.
|
- 3단계 구현 및 테스트가 성공적으로 완료되면, 사용자에게 최종 보고하기 **전에 반드시 먼저** `<PROJECT_ROOT>/Docs/` 디렉토리에 작업 완료(Work done), 트러블슈팅(Troubleshooting), 의사 결정(Decisions made) 내역을 문서화해야 합니다.
|
||||||
- 새 문서가 생성되거나 수정되면 자동으로 `Docs/index.md`에 문서의 색인(링크)을 추가합니다.
|
- 새 문서가 생성되거나 수정되면 자동으로 `Docs/index.md`에 문서의 색인(링크)을 추가합니다.
|
||||||
- 모든 코드 작업 내역과 의사 결정이 완전히 로컬 `Docs/`에 기록 및 정리된 후에만 비로소 "작업을 완료했다"고 사용자에게 알립니다.
|
- 모든 코드 작업 내역과 의사 결정이 완전히 로컬 `Docs/`에 기록 및 정리된 후에만 비로소 "작업을 완료했다"고 사용자에게 알립니다.
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
---
|
---
|
||||||
trigger: model_decision
|
description: Security Rules (새 규칙)
|
||||||
description: security
|
|
||||||
---
|
---
|
||||||
|
|
||||||
이 워크플로우는 프로젝트 진행 시 잊지 않고 준수해야 하는 새로운 보안 규칙을 정의합니다.
|
이 워크플로우는 프로젝트 진행 시 잊지 않고 준수해야 하는 새로운 보안 규칙을 정의합니다.
|
||||||
|
|
||||||
## 핵심 보안 규칙 (Core Security Rules)
|
## 핵심 보안 규칙 (Core Security Rules)
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
# Kord 디자인 원칙 (Design Principles)
|
|
||||||
|
|
||||||
Kord 봇의 기능 설계 및 사용자 경험(UX) 고도화를 위한 핵심 원칙입니다.
|
|
||||||
|
|
||||||
## 1. 부가 기능의 기본 비활성화 (Opt-in by Default)
|
|
||||||
|
|
||||||
사용자의 서버 운영에 필수가 아닌 부가 기능(Fun features, 유틸리티 성격의 부가 기능 등)은 초기 도입 시 **기본적으로 비활성화(Disabled)** 상태여야 합니다.
|
|
||||||
|
|
||||||
- **이유**: 서버 관리자가 의도하지 않은 봇의 반응(메시지 치환, 자동 이모지 확대 등)으로 인한 혼선을 방지하기 위함입니다.
|
|
||||||
- **예시**: 미믹(Mimic), 이모지 확대(Big Emoji) 등.
|
|
||||||
|
|
||||||
## 2. 설정 도우미(Setup Wizard)의 간결성 유지
|
|
||||||
|
|
||||||
`/setup` 명령어를 통해 제공되는 설정 도우미는 서버 운영의 **핵심 필수 설정**에만 집중합니다.
|
|
||||||
|
|
||||||
- **포함 대상**: 언어 설정, 보안/감사 로그 채널, 필수 권한 점검, 핵심 서비스(임시 음성 채널 등) 진입점 설정.
|
|
||||||
- **제외 대상**: 미믹 활성화 여부, 이모지 확대 여부 등 부가적인 환경 설정.
|
|
||||||
- **설정 방법**: 설정 도우미에서 제외된 기능은 별도의 관리 명령어(예: `/config`)를 통해 개별적으로 활성화할 수 있도록 제공합니다.
|
|
||||||
|
|
||||||
## 3. 용어의 일관성 (Terminology)
|
|
||||||
|
|
||||||
기능의 명칭은 기술적 정의와 사용자 친화도 사이의 균형을 맞추며, 한 번 정해진 고유 명사는 일관되게 사용합니다.
|
|
||||||
|
|
||||||
- **예시**: 사용자의 메시지를 흉내 내는 기능은 '흉내'라는 일반 명사 대신 **'미믹(Mimic)'**이라는 고유 명사를 프로젝트 전반(코드, 번역, 문서)에서 사용합니다.
|
|
||||||
|
|
@ -40,14 +40,14 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. ✅ 권한 검사 (Permission Audit)
|
### 2. ⬜ 권한 검사 (Permission Audit)
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **목표** | 봇이 각 기능을 수행하기 위해 충분한 권한을 가지고 있는지 진단하고 보고서를 생성 |
|
| **목표** | 봇이 각 기능을 수행하기 위해 충분한 권한을 가지고 있는지 진단하고 보고서를 생성 |
|
||||||
| **트리거** | 슬래시 명령어 (`/audit-permissions` 등) |
|
| **트리거** | 슬래시 명령어 (`/audit-permissions` 등) |
|
||||||
| **출력** | 실행한 채널에 Embed 형태의 권한 진단 보고서 전송 |
|
| **출력** | 실행한 채널에 Embed 형태의 권한 진단 보고서 전송 |
|
||||||
| **기획서** | [`Permission_Audit_Plan.md`](./Permission_Audit_Plan.md) |
|
| **기획서** | `Docs/Plans/Permission_Audit_Plan.md` *(미작성)* |
|
||||||
|
|
||||||
**핵심 고려사항**
|
**핵심 고려사항**
|
||||||
- 기능별 필요 권한 매핑 테이블 (Feature → Required Permissions)
|
- 기능별 필요 권한 매핑 테이블 (Feature → Required Permissions)
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. ✅ 감사 채널 (Audit Log Channel)
|
### 3. 📝 감사 채널 (Audit Log Channel)
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
|
@ -107,14 +107,14 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6. ✅ 봇 설정 도우미 (Setup Wizard)
|
### 6. ⬜ 봇 설정 도우미 (Setup Wizard)
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **목표** | 서버 관리자가 인터랙션 기반으로 봇의 주요 설정을 단계별로 완료할 수 있는 설정 마법사 제공 |
|
| **목표** | 서버 관리자가 인터랙션 기반으로 봇의 주요 설정을 단계별로 완료할 수 있는 설정 마법사 제공 |
|
||||||
| **트리거** | 슬래시 명령어 (`/setup` 등) |
|
| **트리거** | 슬래시 명령어 (`/setup` 등) |
|
||||||
| **UI 형태** | Embed + Button + Select Menu 조합의 스텝 바이 스텝 인터랙션 |
|
| **UI 형태** | Embed + Button + Select Menu 조합의 스텝 바이 스텝 인터랙션 |
|
||||||
| **기획서** | [`Setup_Wizard_Plan.md`](./Setup_Wizard_Plan.md) |
|
| **기획서** | `Docs/Plans/Setup_Wizard_Plan.md` *(미작성)* |
|
||||||
|
|
||||||
**핵심 고려사항**
|
**핵심 고려사항**
|
||||||
- 설정 항목 정의 (언어, 감사 채널, 임시 음성 채널 생성기 등)
|
- 설정 항목 정의 (언어, 감사 채널, 임시 음성 채널 생성기 등)
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
# 봇 설정 도우미 (Setup Wizard) 기획서
|
|
||||||
|
|
||||||
## 1. 개요
|
|
||||||
|
|
||||||
봇 설정 도우미는 관리자가 Kord 봇을 처음 서버에 추가했거나, 재설정이 필요할 때 직관적인 UI(마법사 형태)를 통해 필수 기능들을 순차적으로 설정할 수 있도록 돕는 기능입니다.
|
|
||||||
|
|
||||||
## 2. 진입점 (Trigger)
|
|
||||||
|
|
||||||
- **명령어**: `/setup`
|
|
||||||
- **실행 권한**: `Administrator` 또는 `Manage Guild` 권한 (명령어 자체의 `defaultMemberPermissions`로 제한)
|
|
||||||
- **실행 위치**: 모든 텍스트 채널 (단, 과정 중 타인의 방해나 혼선을 줄이기 위해 ephemeral(개인만 보기) 형태로 진행)
|
|
||||||
|
|
||||||
## 3. 설정 흐름 (Setup Flow)
|
|
||||||
|
|
||||||
설정은 스텝(Step) 단위로 진행되며, 각 스텝은 Embed 메시지와 하단 컴포넌트(버튼, Select Menu 등)로 구성됩니다.
|
|
||||||
사용자는 **[다음]**, **[이번 스텝 건너뛰기]**, **[설정 종료]** 버튼 등으로 흐름을 제어합니다.
|
|
||||||
|
|
||||||
### Step 0: 환영 및 소개 (Welcome)
|
|
||||||
- **내용**: Kord 봇 설정 마법사 시작을 알리고, 설정될 4가지 주요 항목들을 간략히 소개합니다.
|
|
||||||
(1) 언어 설정, (2) 권한 점검, (3) 감사 채널 지정, (4) 임시 음성 채널 설정
|
|
||||||
- **액션**: `[시작하기]` 버튼 클릭 시 Step 1로 이동.
|
|
||||||
|
|
||||||
### Step 1: 언어 설정 (Language)
|
|
||||||
|
|
||||||
- **내용**: 서버의 기본 언어를 설정합니다. (i18n 기능 연동)
|
|
||||||
- **현재 설정 표시**: 기존에 설정된 언어 표시.
|
|
||||||
- **컴포넌트**: 지원하는 언어(Korean, English)를 선택할 수 있는 `StringSelectMenu`.
|
|
||||||
- **액션**:
|
|
||||||
- 선택 시 즉각적으로 DB 업데이트 및 언어 변경 적용. (이후 UI는 변경된 언어로 릴로드 됨)
|
|
||||||
- `[다음]` / `[건너뛰기]` 버튼.
|
|
||||||
|
|
||||||
### Step 2: 권한 점검 (Permission Check)
|
|
||||||
|
|
||||||
- **내용**: 봇이 원활하게 동작하기 위해 필요한 필수 권한(서버 수준)이 부여되어 있는지 점검합니다.
|
|
||||||
- **컴포넌트**: 점검 통과 상태 (✅ 모두 정상 / ⚠️ 일부 부족). 부족한 경우 권한 부여를 안내합니다. (권한 검사 모듈 `/audit-permissions`의 축소판)
|
|
||||||
- **액션**: `[다시 검사]` / `[다음]` 버튼.
|
|
||||||
|
|
||||||
### Step 3: 감사 로그 채널 설정 (Audit Channel)
|
|
||||||
|
|
||||||
- **내용**: 봇의 주요 이벤트와 에러 로그를 남길 시스템 통보 채널을 지정합니다.
|
|
||||||
- **컴포넌트**:
|
|
||||||
- 텍스트 채널을 선택하는 `ChannelSelectMenu` (ChannelType.GuildText 채널만).
|
|
||||||
- `[사용 안함(비활성화)]` 버튼.
|
|
||||||
- **액션**: 채널 선택 시 DB 갱신 후, Step 4로 이동 (사용 안함 선택 시 Step 5로 이동).
|
|
||||||
|
|
||||||
### Step 4: 감사 로그 카테고리 설정 (Audit Categories)
|
|
||||||
|
|
||||||
- **내용**: 수신할 감사 로그의 종류(음성, 권한, 시스템 등)를 필터링합니다.
|
|
||||||
- **컴포넌트**:
|
|
||||||
- 각 카테고리(SYSTEM, VOICE, PERMISSION, INVITE, MIMIC)를 토글할 수 있는 버튼 5개.
|
|
||||||
- 활성화 상태는 **초록색(Success)**, 비활성화는 **빨간색(Danger)**으로 표시.
|
|
||||||
- **액션**: 버튼 클릭 시 DB의 `disabledCategories` 필드 업데이트 후 현재 뷰 갱신. `[다음 단계]` 버튼으로 이동.
|
|
||||||
|
|
||||||
### Step 5: 임시 음성 채널 설정 (Voice Generator)
|
|
||||||
|
|
||||||
- **내용**: 임시 음성 채널 생성 시스템의 진입점이 될 '생성기 채널'을 지정하거나 새로 생성합니다.
|
|
||||||
- **컴포넌트**:
|
|
||||||
- 기존 생성할 채널을 고르는 `ChannelSelectMenu` (ChannelType.GuildVoice).
|
|
||||||
- `[자동 생성]` 버튼: 봇이 새 음성 카테고리와 "➕ 음성 채널 생성" 채널을 자동으로 만들어 줌.
|
|
||||||
- `[건너뛰기]` 버튼.
|
|
||||||
- **액션**: 설정 시 즉각 시스템 구동. 이후 Step 6(완료 요약)으로 이동.
|
|
||||||
|
|
||||||
### Step 6: 설정 요약 (Summary)
|
|
||||||
|
|
||||||
- **내용**: 지금까지 설정된 모든 항목(내용, 감사 채널/카테고리, 음성 채널)의 최종 상태를 요약하여 보여줍니다.
|
|
||||||
- **컴포넌트**: 설정 결과 요약 Embed.
|
|
||||||
- **액션**: `[설정 마치기]` 버튼 (누르면 "설정이 완료되었습니다"로 메시지 변경 후 버튼 비활성화).
|
|
||||||
|
|
||||||
## 4. 아키텍처 (Architecture)
|
|
||||||
|
|
||||||
- **State Management (상태 관리)**:
|
|
||||||
- 마법사는 ephemeral 메시지로 띄워지며, 세션 식별은 `customId` 릴레이 방식을 권장합니다.
|
|
||||||
- 예시: `setup_action_next_2` (현재 Step 2, 다음으로 이동) 등 Stateless하게 관리하거나,
|
|
||||||
- 사용자/서버별 임시 Map/Redis에 `SetupSession` (진행 단계 등) 보관. (구현 편의상 `customId` payload에 스텝 인덱스를 담는 Stateless 방식 채택)
|
|
||||||
- **i18n 통합**:
|
|
||||||
- 버튼 레이블 및 Embed 내용은 모두 `t()` 함수를 거쳐야 합니다.
|
|
||||||
- Step 1에서 언어가 변경되면, 즉시 그 언어 기준의 `t()`를 이용해 현재 뷰(View)를 갱신합니다.
|
|
||||||
|
|
||||||
## 5. 예외 처리 (Error Handling)
|
|
||||||
|
|
||||||
- 설정 진행 중 타임아웃(Discord 기본 15분 경과) 시: Error Guidance 시스템을 통해 "인터랙션이 만료되었습니다, `/setup`을 다시 실행해주세요" 출력.
|
|
||||||
- 권한 부족: 생성기 `[자동 생성]` 등 채널 생성 로직에서 권한 부족 에러 발생 시 Error Guidance로 팝업 형태(ephemeral) 오류 출력.
|
|
||||||
|
|
@ -20,12 +20,5 @@ Kord 봇의 모든 유저 노출 기능은 글로벌 시장 대응을 위해 다
|
||||||
### 3. 코드 연동
|
### 3. 코드 연동
|
||||||
- `t(locale, 'key', { vars })` 함수를 사용하여 번역된 문자열을 가져옵니다.
|
- `t(locale, 'key', { vars })` 함수를 사용하여 번역된 문자열을 가져옵니다.
|
||||||
|
|
||||||
## 테스트 코드 준수 사항
|
|
||||||
|
|
||||||
테스트 코드에서도 번역된 문자열을 비교할 때는 하드코딩 대신 i18n 시스템을 참조해야 합니다.
|
|
||||||
|
|
||||||
- **검사 방법**: `npm run check-i18n` 명령어를 실행하여 하드코딩된 i18n 값이 있는지 확인합니다.
|
|
||||||
- **예외 처리**: 의도적으로 하드코딩이 필요한 경우(예: i18n 자체 테스트), 해당 줄 끝에 `// i18n-ignore` 주석을 추가합니다.
|
|
||||||
|
|
||||||
## 변경 이력
|
## 변경 이력
|
||||||
- **2026-03-27**: i18n 필수 적용 원칙 수립 및 가이드라인 생성
|
- **2026-03-27**: i18n 필수 적용 원칙 수립 및 가이드라인 생성
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# 2026-03-27: 감사 채널 (Audit Log Channel) 구현 Implementation
|
|
||||||
|
|
||||||
봇의 주요 이벤트와 시스템 상태를 관리자가 지정한 채널로 실시간 통보하는 '감사 채널' 시스템을 구축했습니다.
|
|
||||||
|
|
||||||
## 주요 구현 사항
|
|
||||||
|
|
||||||
### 1. 데이터베이스 및 서비스 레이어
|
|
||||||
- **Prisma 모델 추가**: `AuditChannel` 모델을 추가하여 서버별 로그 채널 정보와 수신 비활성화된 카테고리를 저장합니다.
|
|
||||||
- **`AuditLogService`**:
|
|
||||||
- `log()`: 카테고리(`SYSTEM`, `VOICE`, `PERMISSION`, `INVITE`, `MIMIC`) 및 심각도(`INFO`, `WARN`, `ERROR`)에 따른 필터링된 Embed 알림 전송.
|
|
||||||
- **격리 처리**: 감사 채널 전송 중 발생하는 권한 오류 등은 봇의 주 기능에 영향을 주지 않도록 Silent fail 처리했습니다.
|
|
||||||
|
|
||||||
### 2. 관리자 명령어 (`/audit-channel`)
|
|
||||||
- `set`: 로그를 수신할 텍스트 채널을 지정하고 필수 권한(메시지 전송, 임베드 링크)을 검사합니다.
|
|
||||||
- `clear`: 현재 설정된 감사 채널 정보를 초기화합니다.
|
|
||||||
- `status`: 현재 등록된 채널과 수신이 차단(Muted)된 카테고리 목록을 확인합니다.
|
|
||||||
- `filter`: 특정 로그 카테고리(예: `VOICE`)의 수신 여부를 개별적으로 제어합니다.
|
|
||||||
|
|
||||||
### 3. 시스템 연동
|
|
||||||
- **봇 라이프사이클**: `ready` 이벤트 발생 시 서버당 `SYSTEM` INFO 알림을 통해 시작을 알립니다.
|
|
||||||
- **음성 서비스 연동**: 임시 음성 채널 생성/삭제 시 `VOICE` 로그를 남기며, 권한 부족 시 `PERMISSION` ERROR 로그를 자동으로 전송합니다.
|
|
||||||
- **권한 진단 연동**: `/audit-permissions` 결과 누락 사항이 발견되면 자동으로 요약본을 감사 채널로 보고합니다.
|
|
||||||
|
|
||||||
## 결과 및 검증
|
|
||||||
- Prisma 스키마 검증 및 DB 마이그레이션(`add_audit_channel_model`) 완료.
|
|
||||||
- 채널 권한 부족 시의 적절한 사용자 안내 메시지 출력 확인.
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# 2026-03-27: /config 명령어 구조 개선 및 기능 관리 리팩토링 (Config & Feature Refactoring)
|
|
||||||
|
|
||||||
사용자의 요청에 따라 `/config` 명령어를 서브커맨드 방식에서 **선택적 인자(Options)** 방식으로 개선하고, 주요 기능(Mimic, Big Emoji)의 관리 시스템을 고도화했습니다.
|
|
||||||
|
|
||||||
## 주요 변경 사항
|
|
||||||
|
|
||||||
### 1. 명령어 구조 개선 (`/config`)
|
|
||||||
- **기존**: `/config mimic [true/false]`, `/config emoji [true/false]` (서브커맨드 방식)
|
|
||||||
- **변경**: `/config [mimic: boolean] [emoji: boolean]` (옵션 방식)
|
|
||||||
- 한 번의 명령어로 여러 기능을 동시에 설정할 수 있습니다. (예: `/config mimic:True emoji:False`)
|
|
||||||
- 인자를 입력하지 않은 기능은 기존 서버 설정이 그대로 유지됩니다.
|
|
||||||
|
|
||||||
### 2. 다국어(i18n) 및 결과 화면 최적화
|
|
||||||
- **응답 로직 개선**: 여러 설정을 동시에 변경했을 때 요약된 결과를 한 번에 보여주도록 응답 로직을 개선했습니다.
|
|
||||||
- **키 구조 최적화**: i18n 키 구조(`commands.config.*`)를 명확히 하여 모든 번역이 정상적으로 표시되도록 조치했습니다.
|
|
||||||
- **용어 통일**: '미믹(Mimic)'과 '이모지 확대(Big Emoji)' 레이블을 사용하여 일관성을 확보했습니다.
|
|
||||||
|
|
||||||
### 3. 시스템 안정성 확보
|
|
||||||
- **i18n 복구 및 검증**: 데이터 타입 정의(`types.ts`)와 번역 파일(`en.ts`, `ko.ts`) 간의 불일치를 해결했습니다.
|
|
||||||
- **CommandLoader 수정**: ESM/CJS 혼용 환경에서도 명령어를 안정적으로 불러올 수 있도록 로딩 로직을 강화했습니다.
|
|
||||||
- **유효성 검사**: 아무런 옵션도 입력하지 않고 명령어를 실행할 경우 "변경할 옵션을 선택해달라"는 안내 메시지를 출력하도록 처리했습니다.
|
|
||||||
|
|
||||||
## 작업 결과
|
|
||||||
- `npm run build`: 성공 (타입 안정성 확보)
|
|
||||||
- `Command Registration`: 7개의 주요 명령어(audit-channel, audit-permissions, config, language, setup, voice-config, voice-setup) 등록 확인 완료.
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# 2026-03-27: 에러 안내 UX (Error Guidance) 개선 및 통합 Implementation
|
|
||||||
|
|
||||||
사용자 인터랙션 중 오류 발생 시, 단순히 실패하는 대신 **유형별 친절한 안내(Embed)**를 제공하고 상세 오류는 서버 로그로 격리하는 에러 핸들링 시스템을 구축했습니다.
|
|
||||||
|
|
||||||
## 주요 변경 사항
|
|
||||||
|
|
||||||
### 1. 에러 구조 체계화
|
|
||||||
- **`BotError` 클래스**: 에러 코드(`code`), 카테고리(`category`), 유저 안내 메시지(`userMessage`), 해결 방법(`resolution`)을 포함하는 전용 클래스를 생성했습니다.
|
|
||||||
- **`ErrorCodes.ts`**: E1xxx(입력), E2xxx(권한), E3xxx(내부오류), E4xxx(디스코드 API) 등 체계적인 에러 코드 번호를 정의했습니다.
|
|
||||||
|
|
||||||
### 2. `ErrorReporter` 및 핸들러 래퍼
|
|
||||||
- **`report()`**: 에러 객체를 분석하여 사용자에게는 시각적으로 편안한 Embed 메시지로 변환하여 응답합니다.
|
|
||||||
- **`withErrorHandler()`**: 상위 레벨에서 비즈니스 로직을 감싸, 예외 발생 시 자동으로 번역된 안내 메시지를 출력하고 개발자용 로그를 남기는 래퍼 함수를 도입했습니다.
|
|
||||||
|
|
||||||
### 3. 시스템 전반 적용
|
|
||||||
- **`interactionCreate.ts`**: 모든 명령어 및 컴포넌트 인터랙션의 진입점에 에러 핸들러를 적용했습니다.
|
|
||||||
- **`VoiceService` 및 서비스 레이어**: 로직 중단 없이 에러 유형만 던지는(Throw) 패턴으로 리팩토링하여 상위에서 일괄 처리되도록 개선했습니다.
|
|
||||||
|
|
||||||
## 검증 결과
|
|
||||||
- **TypeScript 빌드**: 컴파일 성공.
|
|
||||||
- **테스크 통과**: 16개의 에러 핸들링 관련 유닛 테스트(tests/errors/*) 모두 통과 확인.
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
# 2026-03-27: 권한 진단 (Permission Audit) 기능 구현 Implementation
|
|
||||||
|
|
||||||
봇의 주요 기능들이 정상 작동하기 위해 필요한 권한들을 자동으로 점검하고 관리자에게 보고하는 진단 시스템을 구현했습니다.
|
|
||||||
|
|
||||||
## 주요 변경 사항
|
|
||||||
|
|
||||||
### 1. `PermissionAuditService` 구현
|
|
||||||
- **기능별 권한 매핑**: 음성 채널 자동화, 초대 추적, Mimic Webhook 등 봇의 주요 기능별 최소 필요 권한을 정의했습니다.
|
|
||||||
- **정밀 진단**: 서버 수준의 전역 권한뿐만 아니라, 음성 생성기 채널 및 카테고리에 설정된 채널별 권한(Override)까지 상세히 스캔합니다.
|
|
||||||
- **계층 구조 검사**: 봇 역할(Role)의 순위가 관리 대상 인원이나 역할보다 높은지 확인하는 계층 구조 검사 로직을 포함했습니다.
|
|
||||||
|
|
||||||
### 2. 관리자 명령어 (`/audit-permissions`)
|
|
||||||
- 관리자가 실시간으로 봇의 권한 상태를 확인할 수 있는 인터페이스를 제공합니다.
|
|
||||||
- **상태 표기**: ✅(정상), ⚠️(채널 권한 주의), ❌(필수 권한 누락) 아이콘을 통해 직관적인 보고서를 출력합니다.
|
|
||||||
- **Actionable Info**: 누락된 권한이 있을 경우 "Missing: ..." 항목을 통해 정확히 어떤 권한을 추가해야 하는지 안내합니다.
|
|
||||||
|
|
||||||
### 3. 다국어 지원 (i18n)
|
|
||||||
- 봇의 언어 설정(EN, KO)에 따라 진단 결과 보고서의 모든 레이블과 설명이 올바르게 번역되어 출력됩니다.
|
|
||||||
|
|
||||||
## 검정 결과
|
|
||||||
- `npx tsc --noEmit`: 컴파일 성공 및 타입 안정성 확인.
|
|
||||||
- Guild 및 Channel 수준의 권한 점검 로직의 정확성 테스트 완료.
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# 2026-03-27: Kord 프로젝트 초기 설정 및 파운데이션 구축 (Initial Setup)
|
|
||||||
|
|
||||||
프로젝트 'Kord'의 기술 기반을 수립하고 핵심적인 개발 인프라를 구축했습니다.
|
|
||||||
|
|
||||||
## 주요 작업 내역
|
|
||||||
|
|
||||||
### 1. 기술 스택 확정 및 초기화
|
|
||||||
- **언어 및 런타임**: Node.js, TypeScript
|
|
||||||
- **프레임워크**: discord.js (v14+)
|
|
||||||
- **데이터베이스**: Prisma (PostgreSQL) + Redis (캐싱 및 동기화)
|
|
||||||
- **빌드 도구**: ts-node, tsx, tsc
|
|
||||||
|
|
||||||
### 2. 프로젝트 기본 구조 설계
|
|
||||||
- `src/commands/`: 슬래시 명령어 핸들링
|
|
||||||
- `src/services/`: 비즈니스 로직 분리
|
|
||||||
- `src/events/`: Discord Gateway 이벤트 리스너
|
|
||||||
- `src/i18n/`: 다국어 지원 파운데이션
|
|
||||||
|
|
||||||
### 3. 핵심 자동화 인프라
|
|
||||||
- **CommandLoader & EventLoader**: 명령어와 이벤트를 자동으로 스캔하고 등록하는 시스템 구현.
|
|
||||||
- **Error Handling System**: 초기 에러 핸들링 구조 도입.
|
|
||||||
|
|
||||||
## 기여 및 결과
|
|
||||||
- 디스코드 봇 기본 구동 확인.
|
|
||||||
- 슬래시 명령어 자동 등록 및 인터랙션 핸들링 성공.
|
|
||||||
- 데이터베이스 연동 및 기본 모델링 완료.
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# 2026-03-27: 임시 음성 채널 고도화 (서버별 설정 및 닉네임 폴백)
|
|
||||||
|
|
||||||
임시 채널 생성 시 서버마다 다른 설정을 유지할 수 있도록 하고, 채널 이름에 보다 적절한 닉네임을 사용하도록 시스템을 개선했습니다.
|
|
||||||
|
|
||||||
## 주요 개선 사항
|
|
||||||
|
|
||||||
### 1. 서버별 독립 프로필 지원
|
|
||||||
- **DB 스키마 변경**: `UserVoiceProfile` 모델을 유저ID와 서버ID의 복합 키(`[userId, guildId]`) 구조로 리팩토링했습니다.
|
|
||||||
- 이를 통해 한 유저가 서버 A에서 설정한 채널 이름 템플릿이나 인원 제한이 서버 B의 활동에 영향을 주지 않도록 독립성을 확보했습니다.
|
|
||||||
|
|
||||||
### 2. 정교한 이름 확인 로직 (`getEffectiveName`)
|
|
||||||
채널 이름 생성 시 사용자의 이름을 다음 우선순위에 따라 결정합니다:
|
|
||||||
1. **서버 닉네임** (Server Nickname)
|
|
||||||
2. **글로벌 디스플레이 이름** (Global Name)
|
|
||||||
3. **사용자명** (Username)
|
|
||||||
4. **사용자 ID** (ID - 최후의 수단 탈출구)
|
|
||||||
|
|
||||||
### 3. 서버 전역 음성 설정 명령어 추가 (`/voice-config`)
|
|
||||||
- `set-name`: 서버 내 모든 임시 채널에 적용될 기본 이름 템플릿 설정 (예: `{{username}}의 방`).
|
|
||||||
- `set-limit`: 서버 기본 인원 제한 설정.
|
|
||||||
- `status`: 현재 서버에 적용된 음성 관리 정책 확인.
|
|
||||||
|
|
||||||
## 검증 결과
|
|
||||||
- **유닛 테스트**: `VoiceService.test.ts`를 통해 닉네임 결정 로직이 의도한 우선순위대로 작동함을 확인했습니다.
|
|
||||||
- **수동 테스트**: 서로 다른 서버에서 한 유저에게 다른 프로필이 각각 독립적으로 저장되고 적용됨을 확인했습니다.
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
# 2026-03-27: i18n 테스트 코드 검사 도구 구현 (i18n Check Tool Implementation)
|
|
||||||
|
|
||||||
테스트 코드 내에서 i18n 번역 키를 참조하지 않고 실제 번역된 문자열을 하드코딩하여 검증하는 부분을 자동으로 탐지하는 도구를 구현했습니다.
|
|
||||||
|
|
||||||
## 배경 및 목적
|
|
||||||
|
|
||||||
- i18n 시스템 도입 후, 테스트 코드에서 번역된 결과값을 직접 비교(`expect().toBe('문자열')`)하는 사례가 발견되었습니다.
|
|
||||||
- 번역 파일(`ko.ts`, `en.ts`)의 내용이 변경될 때 테스트 코드가 함께 깨지는 것을 방지하고, i18n 참조(`t()` 함수 사용)를 강제하기 위함입니다.
|
|
||||||
|
|
||||||
## 주요 구현 사항
|
|
||||||
|
|
||||||
### 1. 검사 스크립트 작성 (`scripts/check-i18n-tests.ts`)
|
|
||||||
|
|
||||||
- **로직**: `src/i18n/locales/`의 번역 데이터를 로드하여 `값 -> 키` 맵을 생성한 뒤, `tests/` 디렉토리 내의 모든 `.ts` 파일을 스캔하여 일치하는 하드코딩된 문자열을 찾습니다.
|
|
||||||
- **예외 처리**:
|
|
||||||
- i18n 키 자체(점`.` 포함 문자열)는 무시합니다.
|
|
||||||
- `t()` 함수의 인자로 사용되는 경우(키 참조)는 무시합니다.
|
|
||||||
- `// i18n-ignore` 주석이 있는 라인은 검사에서 제외합니다.
|
|
||||||
|
|
||||||
### 2. 실행 명령어 추가 (`package.json`)
|
|
||||||
|
|
||||||
- `npm run check-i18n` (또는 `yarn check-i18n`) 명령어를 통해 언제든지 검사를 실행할 수 있습니다.
|
|
||||||
|
|
||||||
## 작업 결과
|
|
||||||
|
|
||||||
- 현재 프로젝트 내에서 10개의 위반 사례를 성공적으로 탐지했습니다.
|
|
||||||
- `i18n.test.ts` 등 의도적으로 하드코딩이 필요한 부분에는 `// i18n-ignore`를 적용하여 예외 처리를 완료했습니다.
|
|
||||||
|
|
||||||
## 향후 계획
|
|
||||||
|
|
||||||
- CI/CD 파이프라인에 해당 검사 단계를 추가하여 코드 품질을 유지할 예정입니다.
|
|
||||||
|
|
@ -2,20 +2,14 @@
|
||||||
|
|
||||||
이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다.
|
이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다.
|
||||||
|
|
||||||
|
|
||||||
## 정책 및 규칙 (Rules)
|
## 정책 및 규칙 (Rules)
|
||||||
|
|
||||||
- [보안 가이드라인 (Security Rules)](Rules/security_guidelines.md)
|
- [보안 가이드라인 (Security Rules)](Rules/security_guidelines.md)
|
||||||
- [다국어 지원 개발 가이드라인 (i18n Development Guidelines)](Rules/i18n_guidelines.md)
|
- [다국어 지원 개발 가이드라인 (i18n Development Guidelines)](Rules/i18n_guidelines.md)
|
||||||
|
|
||||||
|
|
||||||
## 기능 명세 (Features)
|
## 기능 명세 (Features)
|
||||||
|
|
||||||
- [임시 음성 채널 자동화 (Temp Voice Channels)](Features/temp_voice_channels.md)
|
- [임시 음성 채널 자동화 (Temp Voice Channels)](Features/temp_voice_channels.md)
|
||||||
|
|
||||||
|
|
||||||
## 기획서 (Plans)
|
## 기획서 (Plans)
|
||||||
|
|
||||||
- [기능 로드맵 (Feature Roadmap)](Plans/Feature_Roadmap.md)
|
- [기능 로드맵 (Feature Roadmap)](Plans/Feature_Roadmap.md)
|
||||||
- [임시 음성 채널 기능 기획서 (Temp Voice Channel Plan)](Plans/Temp_Voice_Channel_Plan.md)
|
- [임시 음성 채널 기능 기획서 (Temp Voice Channel Plan)](Plans/Temp_Voice_Channel_Plan.md)
|
||||||
- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
||||||
|
|
@ -23,27 +17,15 @@
|
||||||
- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
||||||
- [다국어 지원 기획서 (i18n Plan)](Plans/i18n_Plan.md)
|
- [다국어 지원 기획서 (i18n Plan)](Plans/i18n_Plan.md)
|
||||||
|
|
||||||
|
|
||||||
## 아키텍처 및 정책 결정 (Decisions)
|
## 아키텍처 및 정책 결정 (Decisions)
|
||||||
|
|
||||||
- [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md)
|
- [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md)
|
||||||
|
|
||||||
|
|
||||||
## 트러블슈팅 (Troubleshooting)
|
## 트러블슈팅 (Troubleshooting)
|
||||||
|
|
||||||
- [Voice Channel Missing Permissions (50013) 해결건](Troubleshooting/50013_Missing_Permissions.md)
|
- [Voice Channel Missing Permissions (50013) 해결건](Troubleshooting/50013_Missing_Permissions.md)
|
||||||
- [Temp Voice 유령 채널 미삭제 버그 해결건](Troubleshooting/handleLeave_ghost_channel.md)
|
- [Temp Voice 유령 채널 미삭제 버그 해결건](Troubleshooting/handleLeave_ghost_channel.md)
|
||||||
|
|
||||||
|
|
||||||
## 진행/완료 내역 (Work Done)
|
## 진행/완료 내역 (Work Done)
|
||||||
|
|
||||||
- [2026-03-27: 봇 상태 메시지 기능 구현 (Bot Presence Implementation)](WorkDone/2026-03-27_Presence_Implementation.md)
|
- [2026-03-27: 봇 상태 메시지 기능 구현 (Bot Presence Implementation)](WorkDone/2026-03-27_Presence_Implementation.md)
|
||||||
- [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)
|
||||||
- [2026-03-27: 임시 음성 채널 고도화 (Voice Channels Improvements)](WorkDone/2026-03-27_Voice_Channels_Improvements.md)
|
|
||||||
- [2026-03-27: 다국어 지원 구현 (i18n Implementation)](WorkDone/2026-03-27_i18n_Implementation.md)
|
- [2026-03-27: 다국어 지원 구현 (i18n Implementation)](WorkDone/2026-03-27_i18n_Implementation.md)
|
||||||
- [2026-03-27: i18n 테스트 코드 검사 도구 구현 (i18n Check Tool Implementation)](WorkDone/2026-03-27_i18n_Check_Tool_Implementation.md)
|
|
||||||
- [2026-03-27: /config 명령어 및 기능 관리 리팩토링 (Config & Feature Refactoring)](WorkDone/2026-03-27_Config_And_Feature_Refactoring.md)
|
|
||||||
- [2026-03-27: 감사 채널 구현 (Audit Log Channel Implementation)](WorkDone/2026-03-27_Audit_Log_Channel_Implementation.md)
|
|
||||||
- [2026-03-27: 권한 진단 기능 구현 (Permission Audit Implementation)](WorkDone/2026-03-27_Permission_Audit_Implementation.md)
|
|
||||||
- [2026-03-27: 에러 안내 UX 개선 및 통합 (Error Guidance UX Implementation)](WorkDone/2026-03-27_Error_Guidance_UX_Implementation.md)
|
|
||||||
- [2026-03-27: Kord 프로젝트 초기 설정 (Project Initial Setup)](WorkDone/2026-03-27_Project_Initial_Setup.md)
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "kord",
|
"name": "Kord",
|
||||||
"packageManager": "yarn@4.9.1",
|
"packageManager": "yarn@4.9.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "6.4.1",
|
"@prisma/client": "6.4.1",
|
||||||
|
|
@ -24,7 +24,6 @@
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "jest",
|
"test": "jest"
|
||||||
"check-i18n": "tsx scripts/check-i18n-tests.ts"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "VoiceGuildConfig" (
|
|
||||||
"guildId" TEXT NOT NULL,
|
|
||||||
"defaultNameTemplate" TEXT NOT NULL DEFAULT '{{username}}''s Room',
|
|
||||||
"defaultUserLimit" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "VoiceGuildConfig_pkey" PRIMARY KEY ("guildId")
|
|
||||||
);
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- The primary key for the `UserVoiceProfile` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
|
||||||
- Added the required column `guildId` to the `UserVoiceProfile` table without a default value. This is not possible if the table is not empty.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "UserVoiceProfile" DROP CONSTRAINT "UserVoiceProfile_pkey",
|
|
||||||
ADD COLUMN "guildId" TEXT NOT NULL,
|
|
||||||
ADD CONSTRAINT "UserVoiceProfile_pkey" PRIMARY KEY ("userId", "guildId");
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "GuildConfig" ADD COLUMN "bigEmojiEnabled" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
ALTER COLUMN "mimicEnabled" SET DEFAULT false;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "AuditChannel" ALTER COLUMN "disabledCategories" SET DEFAULT ARRAY['SYSTEM']::TEXT[];
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "AuditChannel" ALTER COLUMN "disabledCategories" SET DEFAULT ARRAY['BOOT', 'SYSTEM']::TEXT[];
|
|
||||||
|
|
@ -10,8 +10,7 @@ datasource db {
|
||||||
model GuildConfig {
|
model GuildConfig {
|
||||||
guildId String @id
|
guildId String @id
|
||||||
prefix String @default("!")
|
prefix String @default("!")
|
||||||
mimicEnabled Boolean @default(false)
|
mimicEnabled Boolean @default(true)
|
||||||
bigEmojiEnabled Boolean @default(false)
|
|
||||||
locale String?
|
locale String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
@ -66,14 +65,11 @@ model TempVoiceChannel {
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserVoiceProfile {
|
model UserVoiceProfile {
|
||||||
userId String
|
userId String @id
|
||||||
guildId String
|
|
||||||
customName String?
|
customName String?
|
||||||
userLimit Int?
|
userLimit Int?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@id([userId, guildId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserLocale {
|
model UserLocale {
|
||||||
|
|
@ -98,15 +94,7 @@ enum DeleteCondition {
|
||||||
model AuditChannel {
|
model AuditChannel {
|
||||||
guildId String @id
|
guildId String @id
|
||||||
channelId String
|
channelId String
|
||||||
disabledCategories String[] @default(["BOOT", "SYSTEM"])
|
disabledCategories String[] @default([])
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model VoiceGuildConfig {
|
|
||||||
guildId String @id
|
|
||||||
defaultNameTemplate String @default("{{username}}'s Room")
|
|
||||||
defaultUserLimit Int @default(0)
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { ko } from '../src/i18n/locales/ko';
|
|
||||||
import { en } from '../src/i18n/locales/en';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전역 설정 및 상수
|
|
||||||
*/
|
|
||||||
const TARGET_DIR = path.join(__dirname, '../tests');
|
|
||||||
const IGNORE_FILES = ['node_modules', '.git'];
|
|
||||||
const LOCALES = { ko, en };
|
|
||||||
|
|
||||||
type I18nEntry = { key: string; locales: string[] };
|
|
||||||
const i18nValueToKey = new Map<string, I18nEntry>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* i18n 객체를 평탄화하여 '값 -> 키' 매핑을 생성합니다.
|
|
||||||
*/
|
|
||||||
function walk(obj: any, prefix = '', locale = '') {
|
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
|
||||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const entry = i18nValueToKey.get(value);
|
|
||||||
if (entry) {
|
|
||||||
if (!entry.locales.includes(locale)) entry.locales.push(locale);
|
|
||||||
} else {
|
|
||||||
i18nValueToKey.set(value, { key: fullKey, locales: [locale] });
|
|
||||||
}
|
|
||||||
} else if (typeof value === 'object' && value !== null) {
|
|
||||||
walk(value, fullKey, locale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로딩
|
|
||||||
for (const [locale, data] of Object.entries(LOCALES)) {
|
|
||||||
walk(data, '', locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 테스트 파일을 재귀적으로 탐색합니다.
|
|
||||||
*/
|
|
||||||
function getFiles(dir: string): string[] {
|
|
||||||
const results: string[] = [];
|
|
||||||
if (!fs.existsSync(dir)) return results;
|
|
||||||
|
|
||||||
const list = fs.readdirSync(dir);
|
|
||||||
for (const file of list) {
|
|
||||||
if (IGNORE_FILES.some(ignore => file.includes(ignore))) continue;
|
|
||||||
const fullPath = path.join(dir, file);
|
|
||||||
const stat = fs.statSync(fullPath);
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
results.push(...getFiles(fullPath));
|
|
||||||
} else if (file.endsWith('.test.ts') || file.endsWith('.spec.ts') || (dir.includes('tests') && file.endsWith('.ts'))) {
|
|
||||||
results.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 파일 내에서 하드코딩된 i18n 값을 찾습니다.
|
|
||||||
*/
|
|
||||||
function checkFile(filePath: string) {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
const lines = content.split('\n');
|
|
||||||
let matchCount = 0;
|
|
||||||
|
|
||||||
lines.forEach((line, index) => {
|
|
||||||
// 0. 무시 주석 체크
|
|
||||||
if (line.includes('i18n-ignore')) return;
|
|
||||||
|
|
||||||
// 따옴표로 둘러싸인 모든 문자열을 찾습니다.
|
|
||||||
// 인덱스를 추적하기 위해 수동으로 문자열을 찾습니다.
|
|
||||||
const regex = /(['"`])(.*?)\1/g;
|
|
||||||
let match;
|
|
||||||
while ((match = regex.exec(line)) !== null) {
|
|
||||||
const fullMatch = match[0];
|
|
||||||
const val = match[2];
|
|
||||||
|
|
||||||
if (i18nValueToKey.has(val)) {
|
|
||||||
const info = i18nValueToKey.get(val)!;
|
|
||||||
|
|
||||||
// 1. 만약 문자열이 i18n 키 자체라면 (점 포함) 무시합니다.
|
|
||||||
if (val === info.key || (val.includes('.') && !val.includes(' '))) continue;
|
|
||||||
|
|
||||||
// 2. t(..., 'key') 에서 'key'가 값과 같은 경우 무시 (매우 드문 경우)
|
|
||||||
// t() 호출 안에 있는지 대략적으로 체크
|
|
||||||
const linePrefix = line.substring(0, match.index);
|
|
||||||
if (linePrefix.trim().endsWith('t(') || linePrefix.includes('t(')) {
|
|
||||||
// 호출 인자로 보인다면 패스 (단순화된 로직)
|
|
||||||
if (val === info.key) continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[FOUND] ${path.relative(process.cwd(), filePath)}:${index + 1}`);
|
|
||||||
console.log(` - Hardcoded: ${fullMatch}`);
|
|
||||||
console.log(` - Suggested: t(locale, '${info.key}')`);
|
|
||||||
console.log(` - Values: "${val}"`);
|
|
||||||
console.log(` - Locales: ${info.locales.join(', ')}`);
|
|
||||||
console.log('');
|
|
||||||
matchCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return matchCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 실행
|
|
||||||
console.log('--- i18n Reference Check in Tests ---');
|
|
||||||
console.log(`Total i18n values loaded: ${i18nValueToKey.size}`);
|
|
||||||
|
|
||||||
const files = getFiles(TARGET_DIR);
|
|
||||||
console.log(`Checking ${files.length} test files...`);
|
|
||||||
|
|
||||||
let totalMatch = 0;
|
|
||||||
files.forEach(file => {
|
|
||||||
totalMatch += checkFile(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (totalMatch === 0) {
|
|
||||||
console.log('✅ No hardcoded i18n values found in tests.');
|
|
||||||
} else {
|
|
||||||
console.log(`❌ Found ${totalMatch} violations.`);
|
|
||||||
}
|
|
||||||
|
|
@ -16,7 +16,8 @@ export class KordClient extends Client {
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
GatewayIntentBits.GuildVoiceStates,
|
GatewayIntentBits.GuildVoiceStates,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
GatewayIntentBits.MessageContent,
|
// GatewayIntentBits.MessageContent, // Privileged -> Disabled for testing
|
||||||
|
// GatewayIntentBits.GuildMembers, // Privileged -> Disabled for testing
|
||||||
GatewayIntentBits.GuildInvites,
|
GatewayIntentBits.GuildInvites,
|
||||||
],
|
],
|
||||||
partials: [Partials.Message, Partials.Channel, Partials.GuildMember],
|
partials: [Partials.Message, Partials.Channel, Partials.GuildMember],
|
||||||
|
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
import {
|
|
||||||
SlashCommandBuilder,
|
|
||||||
PermissionFlagsBits,
|
|
||||||
ChatInputCommandInteraction,
|
|
||||||
ChannelType,
|
|
||||||
EmbedBuilder,
|
|
||||||
Colors,
|
|
||||||
TextChannel
|
|
||||||
} from 'discord.js';
|
|
||||||
import { auditLogService, AuditCategory } from '../services/AuditLogService';
|
|
||||||
import { PermissionAuditService, AuditResult, AuditStatus } from '../services/PermissionAuditService';
|
|
||||||
import { SupportedLocale, t } from '../i18n';
|
|
||||||
|
|
||||||
const STATUS_EMOJI: Record<AuditStatus, string> = {
|
|
||||||
SUCCESS: '✅',
|
|
||||||
WARNING: '⚠️',
|
|
||||||
FAIL: '❌',
|
|
||||||
};
|
|
||||||
|
|
||||||
function getOverallColor(results: AuditResult[]): number {
|
|
||||||
if (results.some((r) => r.status === 'FAIL')) return Colors.Red;
|
|
||||||
if (results.some((r) => r.status === 'WARNING')) return Colors.Yellow;
|
|
||||||
return Colors.Green;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildResultLine(result: AuditResult, locale: SupportedLocale): string {
|
|
||||||
const emoji = STATUS_EMOJI[result.status];
|
|
||||||
const featureName = t(locale, `commands.permissionAudit.features.${result.featureKey}`) || result.featureKey;
|
|
||||||
|
|
||||||
let line = `${emoji} **${featureName}**`;
|
|
||||||
if (result.scope === 'channel' && result.channelId) line += ` (<#${result.channelId}>)`;
|
|
||||||
if (result.missingPermissions.length > 0) line += `\n> \`${result.missingPermissions.join('`, `')}\``;
|
|
||||||
|
|
||||||
if (result.scope === 'hierarchy' && result.hierarchyInfo) {
|
|
||||||
const { targetRoleName, botRolePosition, targetRolePosition } = result.hierarchyInfo;
|
|
||||||
if (result.status === 'FAIL') {
|
|
||||||
line += `\n> ${t(locale, 'commands.permissionAudit.hierarchyWarning', {
|
|
||||||
role: targetRoleName,
|
|
||||||
botPos: String(botRolePosition),
|
|
||||||
targetPos: String(targetRolePosition),
|
|
||||||
})}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName('audit')
|
|
||||||
.setDescription('Manage audit logs and bot permissions.')
|
|
||||||
.setDescriptionLocalizations({
|
|
||||||
ko: '감사 로그 및 봇 권한을 관리합니다.',
|
|
||||||
})
|
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
|
||||||
// --- Channel Subcommand Group ---
|
|
||||||
.addSubcommandGroup(group =>
|
|
||||||
group
|
|
||||||
.setName('channel')
|
|
||||||
.setDescription('Manage audit log channel settings.')
|
|
||||||
.setDescriptionLocalizations({ ko: '감사 채널 설정을 관리합니다.' })
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('set')
|
|
||||||
.setDescription('Set the audit log channel.')
|
|
||||||
.setDescriptionLocalizations({ ko: '감사 채널을 설정합니다.' })
|
|
||||||
.addChannelOption(option =>
|
|
||||||
option.setName('channel')
|
|
||||||
.setDescription('The text channel to use for audit logs.')
|
|
||||||
.setDescriptionLocalizations({ ko: '감사 로그를 기록할 텍스트 채널' })
|
|
||||||
.addChannelTypes(ChannelType.GuildText)
|
|
||||||
.setRequired(true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('clear')
|
|
||||||
.setDescription('Clear the audit log channel.')
|
|
||||||
.setDescriptionLocalizations({ ko: '감사 채널 설정을 해제합니다.' })
|
|
||||||
)
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('status')
|
|
||||||
.setDescription('Check current audit log channel status.')
|
|
||||||
.setDescriptionLocalizations({ ko: '현재 감사 채널 설정 상태를 확인합니다.' })
|
|
||||||
)
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('filter')
|
|
||||||
.setDescription('Enable or disable specific audit log categories.')
|
|
||||||
.setDescriptionLocalizations({ ko: '특정 종류의 감사 로그 수신 여부를 설정합니다.' })
|
|
||||||
.addStringOption(option =>
|
|
||||||
option.setName('category')
|
|
||||||
.setDescription('The category to manage')
|
|
||||||
.setDescriptionLocalizations({ ko: '설정할 카테고리' })
|
|
||||||
.setRequired(true)
|
|
||||||
.addChoices(
|
|
||||||
{ name: 'SYSTEM (System Errors)', value: 'SYSTEM' },
|
|
||||||
{ name: 'BOOT (Bot Online Notifications)', value: 'BOOT' },
|
|
||||||
{ name: 'VOICE (Voice Channels)', value: 'VOICE' },
|
|
||||||
{ name: 'PERMISSION (Permission Issues)', value: 'PERMISSION' },
|
|
||||||
{ name: 'INVITE (Invite Tracking)', value: 'INVITE' },
|
|
||||||
{ name: 'MIMIC (Mimic Features)', value: 'MIMIC' }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addBooleanOption(option =>
|
|
||||||
option.setName('enable')
|
|
||||||
.setDescription('True to receive logs for this category, False to ignore.')
|
|
||||||
.setDescriptionLocalizations({ ko: '해당 카테고리 수신 (true) 또는 차단 (false)' })
|
|
||||||
.setRequired(true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// --- Permissions Subcommand ---
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('permissions')
|
|
||||||
.setDescription('Check if the bot has all required permissions for its features.')
|
|
||||||
.setDescriptionLocalizations({ ko: '봇이 필요한 권한을 가지고 있는지 진단합니다.' })
|
|
||||||
),
|
|
||||||
|
|
||||||
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
|
||||||
const group = interaction.options.getSubcommandGroup();
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
|
||||||
const guild = interaction.guild!;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// --- CHANNEL GROUP ---
|
|
||||||
if (group === 'channel') {
|
|
||||||
if (subcommand === 'set') {
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
|
||||||
const channel = interaction.options.getChannel('channel', true) as TextChannel;
|
|
||||||
const botMember = guild.members.me;
|
|
||||||
if (!botMember) return;
|
|
||||||
const perms = channel.permissionsFor(botMember);
|
|
||||||
|
|
||||||
if (!perms.has(PermissionFlagsBits.SendMessages) || !perms.has(PermissionFlagsBits.EmbedLinks)) {
|
|
||||||
return interaction.editReply({ content: `❌ 봇에게 <#${channel.id}> 채널의 \`메시지 보내기\` 및 \`링크 첨부\` 권한을 부여해주세요.` });
|
|
||||||
}
|
|
||||||
|
|
||||||
await auditLogService.setChannel(guild.id, channel.id);
|
|
||||||
await interaction.editReply({ content: `✅ 감사 채널이 <#${channel.id}>로 설정되었습니다.` });
|
|
||||||
await auditLogService.log(guild, {
|
|
||||||
category: 'SYSTEM',
|
|
||||||
severity: 'INFO',
|
|
||||||
title: 'Audit Channel Configured',
|
|
||||||
description: `This channel has been configured as the audit log channel by <@${interaction.user.id}>.`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subcommand === 'clear') {
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
|
||||||
await auditLogService.clearChannel(guild.id);
|
|
||||||
return interaction.editReply({ content: `✅ 감사 채널 설정이 해제되었습니다.` });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subcommand === 'status') {
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
|
||||||
const config = await auditLogService.getChannel(guild.id);
|
|
||||||
if (!config) return interaction.editReply({ content: `설정된 감사 채널이 없습니다. 먼저 \`/audit channel set\` 명령어로 채널을 설정해주세요.` });
|
|
||||||
|
|
||||||
const disabled = config.disabledCategories.length > 0 ? config.disabledCategories.map((c: string) => `\`${c}\``).join(', ') : '없음 (모두 수신 중)';
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle('🔍 감사 채널 설정 상태')
|
|
||||||
.setColor(Colors.Blue)
|
|
||||||
.addFields(
|
|
||||||
{ name: '지정된 채널', value: `<#${config.channelId}>`, inline: false },
|
|
||||||
{ name: '수신 차단된 카테고리 (Muted)', value: disabled, inline: false }
|
|
||||||
)
|
|
||||||
.setTimestamp();
|
|
||||||
return interaction.editReply({ embeds: [embed] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subcommand === 'filter') {
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
|
||||||
const category = interaction.options.getString('category', true) as AuditCategory;
|
|
||||||
const enable = interaction.options.getBoolean('enable', true);
|
|
||||||
|
|
||||||
const config = await auditLogService.getChannel(guild.id);
|
|
||||||
if (!config) return interaction.editReply({ content: `설정된 감사 채널이 없습니다. 먼저 \`/audit channel set\` 명령어로 채널을 설정해주세요.` });
|
|
||||||
|
|
||||||
await auditLogService.setFilter(guild.id, category, enable);
|
|
||||||
return interaction.editReply({ content: `✅ **${category}** 카테고리의 감사 로그 수신이 **${enable ? '활성화(ON)' : '비활성화(OFF)'}** 되었습니다.` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PERMISSIONS SUBCOMMAND ---
|
|
||||||
if (subcommand === 'permissions') {
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
|
||||||
const results = await PermissionAuditService.auditGuild(guild);
|
|
||||||
if (results.length === 0) return interaction.editReply({ content: t(locale, 'commands.permissionAudit.noResults') });
|
|
||||||
|
|
||||||
const sorted = [...results].sort((a, b) => {
|
|
||||||
const order: Record<AuditStatus, number> = { FAIL: 0, WARNING: 1, SUCCESS: 2 };
|
|
||||||
return order[a.status] - order[b.status];
|
|
||||||
});
|
|
||||||
|
|
||||||
const lines = sorted.map((r) => buildResultLine(r, locale));
|
|
||||||
const failCount = results.filter((r) => r.status === 'FAIL').length;
|
|
||||||
const warnCount = results.filter((r) => r.status === 'WARNING').length;
|
|
||||||
|
|
||||||
let summary = failCount === 0 && warnCount === 0 ? t(locale, 'commands.permissionAudit.summaryOk') : t(locale, 'commands.permissionAudit.summaryIssue', { fail: String(failCount), warn: String(warnCount) });
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle(t(locale, 'commands.permissionAudit.title'))
|
|
||||||
.setDescription(lines.join('\n\n'))
|
|
||||||
.addFields({ name: t(locale, 'commands.permissionAudit.summaryLabel'), value: summary })
|
|
||||||
.setColor(getOverallColor(results))
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
|
||||||
|
|
||||||
if (failCount > 0 || warnCount > 0) {
|
|
||||||
const issues = sorted.filter(r => r.status !== 'SUCCESS');
|
|
||||||
const descLines = issues.map(r => {
|
|
||||||
let text = `- **${r.featureKey}** [${r.status}]`;
|
|
||||||
if (r.missingPermissions.length > 0) text += `\n 누락: \`${r.missingPermissions.join('`, `')}\``;
|
|
||||||
if (r.scope === 'channel') text += `\n 채널: <#${r.channelId}>`;
|
|
||||||
return text;
|
|
||||||
});
|
|
||||||
|
|
||||||
await auditLogService.log(guild, {
|
|
||||||
category: 'PERMISSION',
|
|
||||||
severity: failCount > 0 ? 'ERROR' : 'WARN',
|
|
||||||
title: '권한 감사에서 문제 감지',
|
|
||||||
description: `\`/audit permissions\` 실행 중 권한 문제가 확인되었습니다.\n\n${descLines.join('\n')}`
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in audit command', error);
|
|
||||||
const reply = interaction.deferred ? interaction.editReply : interaction.reply;
|
|
||||||
return (reply as any)({ content: '명령 실행 중 오류가 발생했습니다.', ephemeral: true });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
ChannelType,
|
||||||
|
EmbedBuilder,
|
||||||
|
Colors,
|
||||||
|
TextChannel,
|
||||||
|
} from 'discord.js';
|
||||||
|
import { auditLogService, AuditCategory } from '../services/AuditLogService';
|
||||||
|
import { SupportedLocale, t } from '../i18n';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('audit-channel')
|
||||||
|
.setDescription('Manage the audit log channel settings.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '감사 채널 설정을 관리합니다.',
|
||||||
|
})
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('set')
|
||||||
|
.setDescription('Set the audit log channel.')
|
||||||
|
.setDescriptionLocalizations({ ko: '감사 채널을 설정합니다.' })
|
||||||
|
.addChannelOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('channel')
|
||||||
|
.setDescription('The text channel to use for audit logs.')
|
||||||
|
.setDescriptionLocalizations({ ko: '감사 로그를 기록할 텍스트 채널' })
|
||||||
|
.addChannelTypes(ChannelType.GuildText)
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('clear')
|
||||||
|
.setDescription('Clear the audit log channel.')
|
||||||
|
.setDescriptionLocalizations({ ko: '감사 채널 설정을 해제합니다.' })
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('status')
|
||||||
|
.setDescription('Check current audit log channel status.')
|
||||||
|
.setDescriptionLocalizations({ ko: '현재 감사 채널 설정 상태를 확인합니다.' })
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('filter')
|
||||||
|
.setDescription('Enable or disable specific audit log categories.')
|
||||||
|
.setDescriptionLocalizations({ ko: '특정 종류의 감사 로그 수신 여부를 설정합니다.' })
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('category')
|
||||||
|
.setDescription('The category to manage')
|
||||||
|
.setDescriptionLocalizations({ ko: '설정할 카테고리' })
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'SYSTEM (Boot, Generic Errors)', value: 'SYSTEM' },
|
||||||
|
{ name: 'VOICE (Voice Channels)', value: 'VOICE' },
|
||||||
|
{ name: 'PERMISSION (Permission Issues)', value: 'PERMISSION' },
|
||||||
|
{ name: 'INVITE (Invite Tracking)', value: 'INVITE' },
|
||||||
|
{ name: 'MIMIC (Mimic Features)', value: 'MIMIC' }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addBooleanOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('enable')
|
||||||
|
.setDescription('True to receive logs for this category, False to ignore.')
|
||||||
|
.setDescriptionLocalizations({ ko: '해당 카테고리 수신 (true) 또는 차단 (false)' })
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
||||||
|
if (!interaction.guild) return;
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
|
if (subcommand === 'set') {
|
||||||
|
const channel = interaction.options.getChannel('channel', true);
|
||||||
|
|
||||||
|
// 권한 검증
|
||||||
|
const botMember = interaction.guild.members.me;
|
||||||
|
if (!botMember) return;
|
||||||
|
|
||||||
|
const textChannel = channel as TextChannel;
|
||||||
|
const perms = textChannel.permissionsFor(botMember);
|
||||||
|
|
||||||
|
if (!perms.has(PermissionFlagsBits.SendMessages) || !perms.has(PermissionFlagsBits.EmbedLinks)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `❌ 봇에게 <#${channel.id}> 채널의 \`메시지 보내기(Send Messages)\` 및 \`링크 첨부(Embed Links)\` 권한을 부여해주세요.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLogService.setChannel(interaction.guild.id, channel.id);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `✅ 감사 채널이 <#${channel.id}>로 설정되었습니다.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 테스트 로그 전송
|
||||||
|
await auditLogService.log(interaction.guild, {
|
||||||
|
category: 'SYSTEM',
|
||||||
|
severity: 'INFO',
|
||||||
|
title: 'Audit Channel Configured',
|
||||||
|
description: `This channel has been configured as the audit log channel by <@${interaction.user.id}>.`,
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'clear') {
|
||||||
|
await auditLogService.clearChannel(interaction.guild.id);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `✅ 감사 채널 설정이 해제되었습니다.`,
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'status') {
|
||||||
|
const config = await auditLogService.getChannel(interaction.guild.id);
|
||||||
|
if (!config) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `설정된 감사 채널이 없습니다. 먼저 \`/audit-channel set\` 명령어로 채널을 설정해주세요.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabled = config.disabledCategories.length > 0
|
||||||
|
? config.disabledCategories.map((c: string) => `\`${c}\``).join(', ')
|
||||||
|
: '없음 (모두 수신 중)';
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('🔍 감사 채널 설정 상태')
|
||||||
|
.setColor(Colors.Blue)
|
||||||
|
.addFields(
|
||||||
|
{ name: '지정된 채널', value: `<#${config.channelId}>`, inline: false },
|
||||||
|
{ name: '수신 차단된 카테고리 (Muted)', value: disabled, inline: false }
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
} else if (subcommand === 'filter') {
|
||||||
|
const category = interaction.options.getString('category', true) as AuditCategory;
|
||||||
|
const enable = interaction.options.getBoolean('enable', true);
|
||||||
|
|
||||||
|
const config = await auditLogService.getChannel(interaction.guild.id);
|
||||||
|
if (!config) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `설정된 감사 채널이 없습니다. 먼저 \`/audit-channel set\` 명령어로 채널을 설정해주세요.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFilters = await auditLogService.setFilter(interaction.guild.id, category, enable);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `✅ **${category}** 카테고리의 감사 로그 수신이 **${enable ? '활성화(ON)' : '비활성화(OFF)'}** 되었습니다.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
EmbedBuilder,
|
||||||
|
Colors,
|
||||||
|
} from 'discord.js';
|
||||||
|
import { PermissionAuditService, AuditResult, AuditStatus } from '../services/PermissionAuditService';
|
||||||
|
import { SupportedLocale, t } from '../i18n';
|
||||||
|
import { auditLogService } from '../services/AuditLogService';
|
||||||
|
|
||||||
|
const STATUS_EMOJI: Record<AuditStatus, string> = {
|
||||||
|
SUCCESS: '✅',
|
||||||
|
WARNING: '⚠️',
|
||||||
|
FAIL: '❌',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getOverallColor(results: AuditResult[]): number {
|
||||||
|
if (results.some((r) => r.status === 'FAIL')) return Colors.Red;
|
||||||
|
if (results.some((r) => r.status === 'WARNING')) return Colors.Yellow;
|
||||||
|
return Colors.Green;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResultLine(result: AuditResult, locale: SupportedLocale): string {
|
||||||
|
const emoji = STATUS_EMOJI[result.status];
|
||||||
|
const featureName =
|
||||||
|
t(locale, `commands.permissionAudit.features.${result.featureKey}`) || result.featureKey;
|
||||||
|
|
||||||
|
let line = `${emoji} **${featureName}**`;
|
||||||
|
|
||||||
|
if (result.scope === 'channel' && result.channelId) {
|
||||||
|
line += ` (<#${result.channelId}>)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.missingPermissions.length > 0) {
|
||||||
|
line += `\n> \`${result.missingPermissions.join('`, `')}\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.scope === 'hierarchy' && result.hierarchyInfo) {
|
||||||
|
const { targetRoleName, botRolePosition, targetRolePosition } = result.hierarchyInfo;
|
||||||
|
if (result.status === 'FAIL') {
|
||||||
|
line += `\n> ${t(locale, 'commands.permissionAudit.hierarchyWarning', {
|
||||||
|
role: targetRoleName,
|
||||||
|
botPos: String(botRolePosition),
|
||||||
|
targetPos: String(targetRolePosition),
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('audit-permissions')
|
||||||
|
.setDescription('Check if the bot has all required permissions for its features.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '봇이 원활하게 작동하기 위해 필요한 권한들을 진단합니다.',
|
||||||
|
})
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
|
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
||||||
|
if (!interaction.guild) return;
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
|
const results = await PermissionAuditService.auditGuild(interaction.guild);
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
await interaction.editReply({ content: t(locale, 'commands.permissionAudit.noResults') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결과를 ❌ FAIL → ⚠️ WARNING → ✅ SUCCESS 순으로 정렬
|
||||||
|
const sorted = [...results].sort((a, b) => {
|
||||||
|
const order: Record<AuditStatus, number> = { FAIL: 0, WARNING: 1, SUCCESS: 2 };
|
||||||
|
return order[a.status] - order[b.status];
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = sorted.map((r) => buildResultLine(r, locale));
|
||||||
|
|
||||||
|
const failCount = results.filter((r) => r.status === 'FAIL').length;
|
||||||
|
const warnCount = results.filter((r) => r.status === 'WARNING').length;
|
||||||
|
|
||||||
|
let summary: string;
|
||||||
|
if (failCount === 0 && warnCount === 0) {
|
||||||
|
summary = t(locale, 'commands.permissionAudit.summaryOk');
|
||||||
|
} else {
|
||||||
|
summary = t(locale, 'commands.permissionAudit.summaryIssue', {
|
||||||
|
fail: String(failCount),
|
||||||
|
warn: String(warnCount),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(t(locale, 'commands.permissionAudit.title'))
|
||||||
|
.setDescription(lines.join('\n\n'))
|
||||||
|
.addFields({ name: t(locale, 'commands.permissionAudit.summaryLabel'), value: summary })
|
||||||
|
.setColor(getOverallColor(results))
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
|
||||||
|
if (failCount > 0 || warnCount > 0) {
|
||||||
|
const issues = sorted.filter(r => r.status !== 'SUCCESS');
|
||||||
|
const descLines = issues.map(r => {
|
||||||
|
let text = `- **${r.featureKey}** [${r.status}]`;
|
||||||
|
if (r.missingPermissions.length > 0) text += `\n 누락: \`${r.missingPermissions.join('`, `')}\``;
|
||||||
|
if (r.scope === 'channel') text += `\n 채널: <#${r.channelId}>`;
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLogService.log(interaction.guild, {
|
||||||
|
category: 'PERMISSION',
|
||||||
|
severity: failCount > 0 ? 'ERROR' : 'WARN',
|
||||||
|
title: '권한 감사에서 문제 감지',
|
||||||
|
description: `\`/audit-permissions\` 실행 중 권한 문제가 확인되었습니다.\n\n${descLines.join('\n')}`
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import { SlashCommandBuilder, ChatInputCommandInteraction, PermissionFlagsBits, EmbedBuilder } from 'discord.js';
|
|
||||||
import { prisma } from '../database';
|
|
||||||
import { t, resolveLocale } from '../i18n';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName('config')
|
|
||||||
.setDescription('Configure bot features')
|
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
|
||||||
.addBooleanOption(opt => opt.setName('mimic').setDescription('Enable or disable Mimic feature'))
|
|
||||||
.addBooleanOption(opt => opt.setName('emoji').setDescription('Enable or disable Big Emoji feature')),
|
|
||||||
|
|
||||||
async execute(interaction: ChatInputCommandInteraction) {
|
|
||||||
if (!interaction.guildId) return;
|
|
||||||
|
|
||||||
const mimic = interaction.options.getBoolean('mimic');
|
|
||||||
const emoji = interaction.options.getBoolean('emoji');
|
|
||||||
|
|
||||||
// Resolve proper supported locale
|
|
||||||
const locale = resolveLocale({
|
|
||||||
discordLocale: interaction.locale,
|
|
||||||
guildLocale: interaction.guildLocale?.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mimic === null && emoji === null) {
|
|
||||||
return interaction.reply({
|
|
||||||
content: t(locale, 'commands.config.noOptions'),
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: any = {};
|
|
||||||
if (mimic !== null) updateData.mimicEnabled = mimic;
|
|
||||||
if (emoji !== null) updateData.bigEmojiEnabled = emoji;
|
|
||||||
|
|
||||||
await prisma.guildConfig.upsert({
|
|
||||||
where: { guildId: interaction.guildId },
|
|
||||||
update: updateData,
|
|
||||||
create: {
|
|
||||||
guildId: interaction.guildId,
|
|
||||||
mimicEnabled: mimic ?? false,
|
|
||||||
bigEmojiEnabled: emoji ?? false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const results: string[] = [];
|
|
||||||
if (mimic !== null) {
|
|
||||||
const state = mimic ? t(locale, 'commands.config.mimic.enabled') : t(locale, 'commands.config.mimic.disabled');
|
|
||||||
results.push(`${t(locale, 'commands.config.mimic.label')}: **${state}**`);
|
|
||||||
}
|
|
||||||
if (emoji !== null) {
|
|
||||||
const state = emoji ? t(locale, 'commands.config.emoji.enabled') : t(locale, 'commands.config.emoji.disabled');
|
|
||||||
results.push(`${t(locale, 'commands.config.emoji.label')}: **${state}**`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setColor(0x00AE86)
|
|
||||||
.setTitle(t(locale, 'commands.config.title'))
|
|
||||||
.setDescription(results.join('\n'));
|
|
||||||
|
|
||||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import {
|
|
||||||
SlashCommandBuilder,
|
|
||||||
PermissionFlagsBits,
|
|
||||||
ChatInputCommandInteraction,
|
|
||||||
} from 'discord.js';
|
|
||||||
import { SetupWizardRenderer } from '../services/SetupWizardRenderer';
|
|
||||||
import { SupportedLocale } from '../i18n';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName('setup')
|
|
||||||
.setDescription('Run the setup wizard to configure the bot step by step.')
|
|
||||||
.setDescriptionLocalizations({
|
|
||||||
ko: '설정 마법사를 실행하여 봇의 필수 기능들을 단계별로 설정합니다.',
|
|
||||||
})
|
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
|
||||||
|
|
||||||
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
|
||||||
// deferReply is not used because we can just reply directly with the first step View.
|
|
||||||
const { embed, components } = await SetupWizardRenderer.renderStep(0, interaction, locale);
|
|
||||||
await interaction.reply({
|
|
||||||
embeds: [embed],
|
|
||||||
components,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
import {
|
|
||||||
SlashCommandBuilder,
|
|
||||||
PermissionFlagsBits,
|
|
||||||
ChatInputCommandInteraction,
|
|
||||||
ChannelType,
|
|
||||||
EmbedBuilder,
|
|
||||||
TextChannel
|
|
||||||
} from 'discord.js';
|
|
||||||
import { prisma } from '../database';
|
|
||||||
import { SupportedLocale, t } from '../i18n';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName('voice')
|
|
||||||
.setDescription('Manage temporary voice channels.')
|
|
||||||
.setDescriptionLocalizations({
|
|
||||||
ko: '임시 음성 채널 설정을 관리합니다.',
|
|
||||||
})
|
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
|
||||||
// --- Setup Subcommands ---
|
|
||||||
.addSubcommandGroup(group =>
|
|
||||||
group
|
|
||||||
.setName('setup')
|
|
||||||
.setDescription('Configure the voice generator channel.')
|
|
||||||
.setDescriptionLocalizations({ ko: '음성 생성기 채널을 설정합니다.' })
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('set')
|
|
||||||
.setDescription('Set an existing voice channel as a Generator')
|
|
||||||
.setDescriptionLocalizations({ ko: '기존 음성 채널을 생성기로 설정합니다' })
|
|
||||||
.addChannelOption(option =>
|
|
||||||
option.setName('channel')
|
|
||||||
.setDescription('The voice channel to act as the Generator')
|
|
||||||
.setDescriptionLocalizations({ ko: '생성기로 사용할 음성 채널' })
|
|
||||||
.setRequired(true)
|
|
||||||
.addChannelTypes(ChannelType.GuildVoice)
|
|
||||||
)
|
|
||||||
.addChannelOption(option =>
|
|
||||||
option.setName('category')
|
|
||||||
.setDescription('(Optional) The category where temp channels will be created')
|
|
||||||
.setDescriptionLocalizations({ ko: '(선택) 임시 채널이 생성될 카테고리' })
|
|
||||||
.setRequired(false)
|
|
||||||
.addChannelTypes(ChannelType.GuildCategory)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('create')
|
|
||||||
.setDescription('Create a new voice channel and set it as a Generator')
|
|
||||||
.setDescriptionLocalizations({ ko: '새 음성 채널을 만들고 생성기로 설정합니다' })
|
|
||||||
.addStringOption(option =>
|
|
||||||
option.setName('name')
|
|
||||||
.setDescription('The name of the new generator voice channel')
|
|
||||||
.setDescriptionLocalizations({ ko: '새 생성기 음성 채널의 이름' })
|
|
||||||
.setRequired(true)
|
|
||||||
)
|
|
||||||
.addChannelOption(option =>
|
|
||||||
option.setName('category')
|
|
||||||
.setDescription('(Optional) The category where the new channel will be created')
|
|
||||||
.setDescriptionLocalizations({ ko: '(선택) 새 채널이 생성될 카테고리' })
|
|
||||||
.setRequired(false)
|
|
||||||
.addChannelTypes(ChannelType.GuildCategory)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// --- Config Subcommands ---
|
|
||||||
.addSubcommandGroup(group =>
|
|
||||||
group
|
|
||||||
.setName('config')
|
|
||||||
.setDescription('Manage default settings for temporary channels.')
|
|
||||||
.setDescriptionLocalizations({ ko: '임시 채널의 기본 설정을 관리합니다.' })
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('name')
|
|
||||||
.setDescription('Set the default naming template for new temp channels.')
|
|
||||||
.setDescriptionLocalizations({ ko: '임시 채널의 기본 이름 템플릿을 설정합니다.' })
|
|
||||||
.addStringOption(option =>
|
|
||||||
option.setName('template')
|
|
||||||
.setDescription('Template using {{username}} placeholder')
|
|
||||||
.setDescriptionLocalizations({ ko: '{{username}}을 포함한 이름 템플릿' })
|
|
||||||
.setRequired(true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('limit')
|
|
||||||
.setDescription('Set the default user limit for new temp channels.')
|
|
||||||
.setDescriptionLocalizations({ ko: '임시 채널의 기본 인원 제한을 설정합니다.' })
|
|
||||||
.addIntegerOption(option =>
|
|
||||||
option.setName('limit')
|
|
||||||
.setDescription('User limit (0-99, 0 = unlimited)')
|
|
||||||
.setDescriptionLocalizations({ ko: '인원 제한 (0-99, 0 = 무제한)' })
|
|
||||||
.setRequired(true)
|
|
||||||
.setMinValue(0)
|
|
||||||
.setMaxValue(99)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addSubcommand(subcommand =>
|
|
||||||
subcommand
|
|
||||||
.setName('status')
|
|
||||||
.setDescription('View current guild voice settings.')
|
|
||||||
.setDescriptionLocalizations({ ko: '현재 서버의 음성 설정을 확인합니다.' })
|
|
||||||
)
|
|
||||||
),
|
|
||||||
|
|
||||||
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
|
||||||
const group = interaction.options.getSubcommandGroup();
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
|
||||||
const guildId = interaction.guildId!;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// --- SETUP GROUP ---
|
|
||||||
if (group === 'setup') {
|
|
||||||
const category = interaction.options.getChannel('category');
|
|
||||||
|
|
||||||
if (subcommand === 'set') {
|
|
||||||
const channel = interaction.options.getChannel('channel', true);
|
|
||||||
await prisma.voiceGenerator.upsert({
|
|
||||||
where: { channelId: channel.id },
|
|
||||||
update: { categoryId: category?.id || null, guildId },
|
|
||||||
create: { channelId: channel.id, guildId, categoryId: category?.id || null }
|
|
||||||
});
|
|
||||||
return interaction.reply({
|
|
||||||
content: t(locale, 'commands.voiceSetup.setSuccess', { channel: `${channel}` }),
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subcommand === 'create') {
|
|
||||||
const name = interaction.options.getString('name', true);
|
|
||||||
const newChannel = await interaction.guild!.channels.create({
|
|
||||||
name,
|
|
||||||
type: ChannelType.GuildVoice,
|
|
||||||
parent: category?.id || null,
|
|
||||||
});
|
|
||||||
await prisma.voiceGenerator.create({
|
|
||||||
data: { channelId: newChannel.id, guildId, categoryId: category?.id || null }
|
|
||||||
});
|
|
||||||
return interaction.reply({
|
|
||||||
content: t(locale, 'commands.voiceSetup.createSuccess', { channel: `${newChannel}` }),
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CONFIG GROUP ---
|
|
||||||
if (group === 'config') {
|
|
||||||
if (subcommand === 'name') {
|
|
||||||
const template = interaction.options.getString('template', true);
|
|
||||||
await prisma.voiceGuildConfig.upsert({
|
|
||||||
where: { guildId },
|
|
||||||
update: { defaultNameTemplate: template },
|
|
||||||
create: { guildId, defaultNameTemplate: template }
|
|
||||||
});
|
|
||||||
return interaction.reply({
|
|
||||||
content: t(locale, 'commands.voiceConfig.setSuccess'),
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subcommand === 'limit') {
|
|
||||||
const limit = interaction.options.getInteger('limit', true);
|
|
||||||
await prisma.voiceGuildConfig.upsert({
|
|
||||||
where: { guildId },
|
|
||||||
update: { defaultUserLimit: limit },
|
|
||||||
create: { guildId, defaultUserLimit: limit }
|
|
||||||
});
|
|
||||||
return interaction.reply({
|
|
||||||
content: t(locale, 'commands.voiceConfig.setSuccess'),
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subcommand === 'status') {
|
|
||||||
const config = await prisma.voiceGuildConfig.findUnique({ where: { guildId } });
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle(t(locale, 'commands.voiceConfig.statusTitle'))
|
|
||||||
.setColor(0x5865F2)
|
|
||||||
.addFields(
|
|
||||||
{
|
|
||||||
name: t(locale, 'commands.voiceConfig.templateLabel'),
|
|
||||||
value: `\`${config?.defaultNameTemplate || t(locale, 'voice.defaultRoomName')}\``,
|
|
||||||
inline: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t(locale, 'commands.voiceConfig.limitLabel'),
|
|
||||||
value: t(locale, 'commands.voiceConfig.limitValue', { limit: String(config?.defaultUserLimit ?? 0) }),
|
|
||||||
inline: true
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error in voice command', error);
|
|
||||||
return interaction.reply({
|
|
||||||
content: t(locale, 'errors.E3003.userMessage'),
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction, ChannelType } from 'discord.js';
|
||||||
|
import { prisma } from '../database';
|
||||||
|
import { SupportedLocale } from '../i18n';
|
||||||
|
import { t } from '../i18n';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('voice-setup')
|
||||||
|
.setDescription('Setup a generator voice channel for temporary channels.')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '임시 음성 채널을 위한 생성기 채널을 설정합니다.',
|
||||||
|
})
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('set')
|
||||||
|
.setDescription('Set an existing voice channel as a Generator')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '기존 음성 채널을 생성기로 설정합니다',
|
||||||
|
})
|
||||||
|
.addChannelOption(option =>
|
||||||
|
option.setName('channel')
|
||||||
|
.setDescription('The voice channel to act as the Generator')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '생성기로 사용할 음성 채널',
|
||||||
|
})
|
||||||
|
.setRequired(true)
|
||||||
|
.addChannelTypes(ChannelType.GuildVoice)
|
||||||
|
)
|
||||||
|
.addChannelOption(option =>
|
||||||
|
option.setName('category')
|
||||||
|
.setDescription('(Optional) The category where temp channels will be created')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '(선택) 임시 채널이 생성될 카테고리',
|
||||||
|
})
|
||||||
|
.setRequired(false)
|
||||||
|
.addChannelTypes(ChannelType.GuildCategory)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('create')
|
||||||
|
.setDescription('Create a new voice channel and set it as a Generator')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '새 음성 채널을 만들고 생성기로 설정합니다',
|
||||||
|
})
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('name')
|
||||||
|
.setDescription('The name of the new generator voice channel')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '새 생성기 음성 채널의 이름',
|
||||||
|
})
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
.addChannelOption(option =>
|
||||||
|
option.setName('category')
|
||||||
|
.setDescription('(Optional) The category where the new channel will be created')
|
||||||
|
.setDescriptionLocalizations({
|
||||||
|
ko: '(선택) 새 채널이 생성될 카테고리',
|
||||||
|
})
|
||||||
|
.setRequired(false)
|
||||||
|
.addChannelTypes(ChannelType.GuildCategory)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
const category = interaction.options.getChannel('category');
|
||||||
|
|
||||||
|
if (subcommand === 'set') {
|
||||||
|
const channel = interaction.options.getChannel('channel', true);
|
||||||
|
|
||||||
|
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: t(locale, 'commands.voiceSetup.setSuccess', { channel: `${channel}` }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'create') {
|
||||||
|
const name = interaction.options.getString('name', true);
|
||||||
|
const guild = interaction.guild!;
|
||||||
|
|
||||||
|
const newChannel = await guild.channels.create({
|
||||||
|
name: name,
|
||||||
|
type: ChannelType.GuildVoice,
|
||||||
|
parent: category?.id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.voiceGenerator.create({
|
||||||
|
data: {
|
||||||
|
channelId: newChannel.id,
|
||||||
|
guildId: guild.id,
|
||||||
|
categoryId: category?.id || null,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'commands.voiceSetup.createSuccess', { channel: `${newChannel}` }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
import { hostname } from 'os';
|
|
||||||
config();
|
config();
|
||||||
|
|
||||||
const generateInstanceId = () => {
|
|
||||||
return process.env.INSTANCE_ID || hostname() || `kord-${Math.random().toString(36).substring(2, 7)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||||
DISCORD_TOKEN: process.env.DISCORD_TOKEN || '',
|
DISCORD_TOKEN: process.env.DISCORD_TOKEN || '',
|
||||||
|
|
@ -15,5 +10,4 @@ export const env = {
|
||||||
REDIS_PORT: parseInt(process.env.REDIS_PORT || '6379', 10),
|
REDIS_PORT: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||||
VOICE_WAITING_ROOM_ID: process.env.VOICE_WAITING_ROOM_ID || '',
|
VOICE_WAITING_ROOM_ID: process.env.VOICE_WAITING_ROOM_ID || '',
|
||||||
VOICE_CATEGORY_ID: process.env.VOICE_CATEGORY_ID || '',
|
VOICE_CATEGORY_ID: process.env.VOICE_CATEGORY_ID || '',
|
||||||
INSTANCE_ID: generateInstanceId(),
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { ErrorDefs, createBotError } from '../errors/ErrorCodes';
|
||||||
import { ErrorReporter, withErrorHandler } from '../errors/ErrorReporter';
|
import { ErrorReporter, withErrorHandler } from '../errors/ErrorReporter';
|
||||||
import { t } from '../i18n';
|
import { t } from '../i18n';
|
||||||
import { getInteractionLocale } from '../i18n/localeHelper';
|
import { getInteractionLocale } from '../i18n/localeHelper';
|
||||||
import { handleSetupWizardInteraction } from '../interactions/handlers/setupWizardHandler';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.InteractionCreate,
|
name: Events.InteractionCreate,
|
||||||
|
|
@ -22,12 +21,6 @@ export default {
|
||||||
await command.execute(interaction, locale);
|
await command.execute(interaction, locale);
|
||||||
}, locale);
|
}, locale);
|
||||||
}
|
}
|
||||||
else if (interaction.isMessageComponent() && interaction.customId.startsWith('setup_')) {
|
|
||||||
const locale = await getInteractionLocale(interaction);
|
|
||||||
await withErrorHandler(interaction, async () => {
|
|
||||||
await handleSetupWizardInteraction(interaction, locale);
|
|
||||||
}, locale);
|
|
||||||
}
|
|
||||||
else if (interaction.isStringSelectMenu()) {
|
else if (interaction.isStringSelectMenu()) {
|
||||||
const customId = interaction.customId;
|
const customId = interaction.customId;
|
||||||
|
|
||||||
|
|
@ -146,9 +139,9 @@ export default {
|
||||||
await voiceChannel.setName(newName);
|
await voiceChannel.setName(newName);
|
||||||
|
|
||||||
await prisma.userVoiceProfile.upsert({
|
await prisma.userVoiceProfile.upsert({
|
||||||
where: { userId_guildId: { userId: ownerId, guildId: interaction.guildId! } },
|
where: { userId: ownerId },
|
||||||
update: { customName: newName },
|
update: { customName: newName },
|
||||||
create: { userId: ownerId, guildId: interaction.guildId!, customName: newName }
|
create: { userId: ownerId, customName: newName }
|
||||||
});
|
});
|
||||||
|
|
||||||
await interaction.reply({ content: t(locale, 'voice.responses.channelRenamed', { name: newName }), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'voice.responses.channelRenamed', { name: newName }), ephemeral: true });
|
||||||
|
|
@ -162,9 +155,9 @@ export default {
|
||||||
await voiceChannel.setUserLimit(limit);
|
await voiceChannel.setUserLimit(limit);
|
||||||
|
|
||||||
await prisma.userVoiceProfile.upsert({
|
await prisma.userVoiceProfile.upsert({
|
||||||
where: { userId_guildId: { userId: ownerId, guildId: interaction.guildId! } },
|
where: { userId: ownerId },
|
||||||
update: { userLimit: limit },
|
update: { userLimit: limit },
|
||||||
create: { userId: ownerId, guildId: interaction.guildId!, userLimit: limit }
|
create: { userId: ownerId, userLimit: limit }
|
||||||
});
|
});
|
||||||
|
|
||||||
const limitDisplay = limit === 0 ? t(locale, 'voice.responses.limitUnlimited') : String(limit);
|
const limitDisplay = limit === 0 ? t(locale, 'voice.responses.limitUnlimited') : String(limit);
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,10 @@
|
||||||
import { Events, Message } from 'discord.js';
|
import { Events, Message } from 'discord.js';
|
||||||
import { MimicService } from '../services/MimicService';
|
import { MimicService } from '../services/MimicService';
|
||||||
import { BigEmojiService } from '../services/BigEmojiService';
|
|
||||||
import { prisma } from '../database';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.MessageCreate,
|
name: Events.MessageCreate,
|
||||||
once: false,
|
once: false,
|
||||||
async execute(message: Message) {
|
async execute(message: Message) {
|
||||||
if (!message.guildId || message.author.bot) return;
|
|
||||||
|
|
||||||
const config = await prisma.guildConfig.findUnique({
|
|
||||||
where: { guildId: message.guildId }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (config?.bigEmojiEnabled) {
|
|
||||||
await BigEmojiService.handleMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config?.mimicEnabled) {
|
|
||||||
await MimicService.handleMessage(message);
|
await MimicService.handleMessage(message);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import { InviteService } from '../services/InviteService';
|
||||||
import { VoiceService } from '../services/VoiceService';
|
import { VoiceService } from '../services/VoiceService';
|
||||||
import { PresenceService } from '../services/PresenceService';
|
import { PresenceService } from '../services/PresenceService';
|
||||||
import { auditLogService } from '../services/AuditLogService';
|
import { auditLogService } from '../services/AuditLogService';
|
||||||
import { redis } from '../cache';
|
|
||||||
import { env } from '../config/env';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.ClientReady,
|
name: Events.ClientReady,
|
||||||
|
|
@ -18,27 +16,19 @@ export default {
|
||||||
PresenceService.startActivePresence(client);
|
PresenceService.startActivePresence(client);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lockKey = 'commands:sync:lock';
|
|
||||||
// EX 300 = 5 minutes lock. Only one instance needs to do this per boot cycle.
|
|
||||||
const acquired = await redis.set(lockKey, '1', 'EX', 300, 'NX');
|
|
||||||
|
|
||||||
if (acquired) {
|
|
||||||
const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON());
|
const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON());
|
||||||
await client.application?.commands.set(commandsData);
|
await client.application?.commands.set(commandsData);
|
||||||
logger.info(`Successfully registered ${commandsData.length} global application commands.`);
|
logger.info(`Successfully registered ${commandsData.length} global application commands.`);
|
||||||
} else {
|
|
||||||
logger.info('Global commands registration skipped (already handled by another instance).');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to register global commands', e);
|
logger.error('Failed to register global commands', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
client.guilds.cache.forEach(guild => {
|
client.guilds.cache.forEach(guild => {
|
||||||
auditLogService.log(guild, {
|
auditLogService.log(guild, {
|
||||||
category: 'BOOT',
|
category: 'SYSTEM',
|
||||||
severity: 'INFO',
|
severity: 'INFO',
|
||||||
title: 'Bot Online',
|
title: 'Bot Online',
|
||||||
description: `Kord instance **[${env.INSTANCE_ID}]** has successfully started or reconnected.`
|
description: `Kord has successfully started or reconnected.`
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,12 @@ export const loadCommands = async (client: KordClient) => {
|
||||||
|
|
||||||
for (const file of commandFiles) {
|
for (const file of commandFiles) {
|
||||||
const filePath = path.join(commandsPath, file);
|
const filePath = path.join(commandsPath, file);
|
||||||
try {
|
const command = require(filePath).default;
|
||||||
const module = require(filePath);
|
|
||||||
const command = module.default || module;
|
|
||||||
|
|
||||||
if (command && 'data' in command && 'execute' in command) {
|
if (command && 'data' in command && 'execute' in command) {
|
||||||
client.commands.set(command.data.name, command);
|
client.commands.set(command.data.name, command);
|
||||||
logger.debug(`Loaded command: ${command.data.name}`);
|
logger.debug(`Loaded command: ${command.data.name}`);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`The command at ${filePath} is missing a required "data" or "execute" property.`);
|
logger.warn(`The command at ${filePath} is missing a required "data" or "execute" property.`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to load command at ${filePath}:`, err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -123,18 +123,6 @@ export const en: TranslationSchema = {
|
||||||
setSuccess: 'Successfully set up {{channel}} as a Voice Generator Channel!',
|
setSuccess: 'Successfully set up {{channel}} as a Voice Generator Channel!',
|
||||||
createSuccess: 'Successfully created and set up {{channel}} as a Voice Generator Channel!',
|
createSuccess: 'Successfully created and set up {{channel}} as a Voice Generator Channel!',
|
||||||
},
|
},
|
||||||
voiceConfig: {
|
|
||||||
description: 'Manage guild-specific settings for temporary voice channels.',
|
|
||||||
setNameTitle: 'Set Default Name Template',
|
|
||||||
setNameDesc: 'Set the default naming format for new temp channels. (Username placeholder: {{username}})',
|
|
||||||
setLimitTitle: 'Set Default User Limit',
|
|
||||||
setLimitDesc: 'Set the default user limit for new temp channels.',
|
|
||||||
statusTitle: 'Current Server Voice Settings',
|
|
||||||
templateLabel: 'Name Template',
|
|
||||||
limitLabel: 'Default User Limit',
|
|
||||||
setSuccess: 'Server temporary channel settings updated successfully.',
|
|
||||||
limitValue: '{{limit}} users (0 = unlimited)',
|
|
||||||
},
|
|
||||||
language: {
|
language: {
|
||||||
description: 'Set the language for the bot.',
|
description: 'Set the language for the bot.',
|
||||||
scopeDescription: 'Apply to yourself or the entire server',
|
scopeDescription: 'Apply to yourself or the entire server',
|
||||||
|
|
@ -163,78 +151,6 @@ export const en: TranslationSchema = {
|
||||||
MIMIC_WEBHOOK: 'Message Mimic (Webhook)',
|
MIMIC_WEBHOOK: 'Message Mimic (Webhook)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
|
||||||
description: 'Run the setup wizard to configure the bot step by step.',
|
|
||||||
step0: {
|
|
||||||
title: '✨ Bot Setup Wizard',
|
|
||||||
desc: 'Welcome! This wizard will help you configure the following 4 features:\n\n1️⃣ **Language Settings**\n2️⃣ **Permission Check**\n3️⃣ **Audit Channel Setup**\n4️⃣ **Temporary Voice Channel Setup**',
|
|
||||||
startBtn: 'Start Setup'
|
|
||||||
},
|
|
||||||
step1: {
|
|
||||||
title: '1️⃣ Language Settings',
|
|
||||||
desc: 'Set the default language for the bot in this server. (Current: **{{locale}}**)',
|
|
||||||
placeholder: 'Select a language',
|
|
||||||
nextBtn: 'Next Step',
|
|
||||||
skipBtn: 'Skip'
|
|
||||||
},
|
|
||||||
step2: {
|
|
||||||
title: '2️⃣ Permission Check',
|
|
||||||
descOk: '✅ **All required permissions are granted.**',
|
|
||||||
descFail: '⚠️ **Some permissions are missing.**\nPlease check the results and grant the necessary permissions to the bot role.',
|
|
||||||
recheckBtn: 'Re-check',
|
|
||||||
nextBtn: 'Next Step'
|
|
||||||
},
|
|
||||||
step3: {
|
|
||||||
title: '3️⃣ Audit Channel Setup',
|
|
||||||
desc: 'Select a channel to receive bot events and error logs.',
|
|
||||||
placeholder: 'Select Audit Channel',
|
|
||||||
disableBtn: 'Disable Audit Logs',
|
|
||||||
nextBtn: 'Next Step'
|
|
||||||
},
|
|
||||||
step4: {
|
|
||||||
title: '3-1️⃣ Audit Log Categories',
|
|
||||||
desc: 'Select which log categories to receive. **Green** buttons are enabled, **Red** buttons are disabled.',
|
|
||||||
nextBtn: 'Next Step',
|
|
||||||
},
|
|
||||||
step5: {
|
|
||||||
title: '4️⃣ Temporary Voice Channel Setup',
|
|
||||||
desc: 'Select the "Generator Channel" for temporary voice channels.\nYou can choose an existing channel or have the bot **auto-create** a new category and channel.',
|
|
||||||
placeholder: 'Select Generator Channel',
|
|
||||||
autoBtn: '🚀 Auto Create',
|
|
||||||
skipBtn: 'Disable Temp Voice',
|
|
||||||
nextBtn: 'Finish Setup'
|
|
||||||
},
|
|
||||||
step6: {
|
|
||||||
title: '🎉 Setup Summary',
|
|
||||||
desc: '**1. Language**: {{lang}}\n**2. Audit Channel**: {{audit}}\n**3. Audit Categories**: {{categories}}\n**4. Temp Voice**: {{voice}}',
|
|
||||||
finishBtn: 'Done'
|
|
||||||
},
|
|
||||||
finished: '✅ The setup wizard has been finished.',
|
|
||||||
expired: '⏳ The session has expired. Please run `/setup` again.',
|
|
||||||
defaultCategoryName: 'Voice Channels',
|
|
||||||
defaultGeneratorName: '➕ Create Channel',
|
|
||||||
auditCategories: {
|
|
||||||
SYSTEM: 'System',
|
|
||||||
BOOT: 'Boot',
|
|
||||||
VOICE: 'Voice',
|
|
||||||
PERMISSION: 'Permission',
|
|
||||||
INVITE: 'Invite',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
title: 'Feature Configuration',
|
|
||||||
noOptions: 'Please provide at least one option to configure.',
|
|
||||||
mimic: {
|
|
||||||
label: 'Mimic',
|
|
||||||
enabled: 'enabled',
|
|
||||||
disabled: 'disabled',
|
|
||||||
},
|
|
||||||
emoji: {
|
|
||||||
label: 'Big Emoji',
|
|
||||||
enabled: 'enabled',
|
|
||||||
disabled: 'disabled',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Modals ──────────────────────────────────────────────
|
// ── Modals ──────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -123,18 +123,6 @@ export const ko: TranslationSchema = {
|
||||||
setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!',
|
setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!',
|
||||||
createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성 및 설정했습니다!',
|
createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성 및 설정했습니다!',
|
||||||
},
|
},
|
||||||
voiceConfig: {
|
|
||||||
description: '서버의 임시 음성 채널 설정을 관리합니다.',
|
|
||||||
setNameTitle: '기본 이름 템플릿 설정',
|
|
||||||
setNameDesc: '임시 채널 생성 시 사용할 기본 이름 형식을 설정합니다. (사용자명: {{username}})',
|
|
||||||
setLimitTitle: '기본 인원 제한 설정',
|
|
||||||
setLimitDesc: '임시 채널 생성 시 적용할 기본 인원 제한을 설정합니다.',
|
|
||||||
statusTitle: '현재 서버 음성 설정',
|
|
||||||
templateLabel: '이름 템플릿',
|
|
||||||
limitLabel: '기본 인원 제한',
|
|
||||||
setSuccess: '서버의 임시 채널 설정이 업데이트되었습니다.',
|
|
||||||
limitValue: '{{limit}}명 (0 = 무제한)',
|
|
||||||
},
|
|
||||||
language: {
|
language: {
|
||||||
description: '봇의 언어를 설정합니다.',
|
description: '봇의 언어를 설정합니다.',
|
||||||
scopeDescription: '본인에게만 또는 서버 전체에 적용',
|
scopeDescription: '본인에게만 또는 서버 전체에 적용',
|
||||||
|
|
@ -163,78 +151,6 @@ export const ko: TranslationSchema = {
|
||||||
MIMIC_WEBHOOK: '메시지 흉내 (Webhook)',
|
MIMIC_WEBHOOK: '메시지 흉내 (Webhook)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
|
||||||
description: '설정 마법사를 실행하여 봇의 필수 기능들을 단계별로 설정합니다.',
|
|
||||||
step0: {
|
|
||||||
title: '✨ 봇 설정 마법사 시작',
|
|
||||||
desc: '환영합니다! 이 마법사를 통해 아래 4가지 항목을 설정합니다.\n\n1️⃣ **언어 설정**\n2️⃣ **필수 권한 점검**\n3️⃣ **감사 채널 설정**\n4️⃣ **임시 음성 채널 설정**',
|
|
||||||
startBtn: '설정 시작하기'
|
|
||||||
},
|
|
||||||
step1: {
|
|
||||||
title: '1️⃣ 언어 설정',
|
|
||||||
desc: '서버 전체에 적용될 봇의 기본 언어를 선택하세요. (현재: **{{locale}}**)',
|
|
||||||
placeholder: '언어를 선택하세요',
|
|
||||||
nextBtn: '다음 단계',
|
|
||||||
skipBtn: '건너뛰기'
|
|
||||||
},
|
|
||||||
step2: {
|
|
||||||
title: '2️⃣ 필수 권한 점검',
|
|
||||||
descOk: '✅ **모든 필수 권한이 정상적으로 부여되어 있습니다.**',
|
|
||||||
descFail: '⚠️ **일부 권한이 부족합니다.**\n결과를 확인하고 봇 역할에 필요한 기능 권한을 부여해주세요.',
|
|
||||||
recheckBtn: '다시 검사하기',
|
|
||||||
nextBtn: '다음 단계'
|
|
||||||
},
|
|
||||||
step3: {
|
|
||||||
title: '3️⃣ 감사 채널 설정',
|
|
||||||
desc: '봇의 주요 이벤트와 에러 통보를 받을 채널을 선택해주세요.',
|
|
||||||
placeholder: '감사 통보 채널 선택',
|
|
||||||
disableBtn: '감사 채널 끄기/해제',
|
|
||||||
nextBtn: '다음 단계'
|
|
||||||
},
|
|
||||||
step4: {
|
|
||||||
title: '감사 로그 카테고리 설정',
|
|
||||||
desc: '로그를 수신할 카테고리를 선택해주세요.',
|
|
||||||
nextBtn: '다음 단계',
|
|
||||||
},
|
|
||||||
step5: {
|
|
||||||
title: '4️⃣ 임시 음성 채널 설정',
|
|
||||||
desc: '임시 음성 채널을 생성할 "생성기 채널"을 선택해주세요.\n기존의 채널을 고르거나 카테고리/채널을 봇이 **자동 생성**하게 할 수도 있습니다.',
|
|
||||||
placeholder: '생성기로 쓸 음성 채널 선택',
|
|
||||||
autoBtn: '🚀 자동 생성하기',
|
|
||||||
skipBtn: '임시 음성 사용 안함',
|
|
||||||
nextBtn: '설정 완료'
|
|
||||||
},
|
|
||||||
step6: {
|
|
||||||
title: '🎉 설정 완료 요약',
|
|
||||||
desc: '**1. 언어**: {{lang}}\n**2. 감사 채널**: {{audit}}\n**3. 감사 카테고리**: {{categories}}\n**4. 임시 음성 채널**: {{voice}}',
|
|
||||||
finishBtn: '마치기'
|
|
||||||
},
|
|
||||||
finished: '✅ 설정 마법사를 종료했습니다.',
|
|
||||||
expired: '⏳ 시간이 만료되었습니다. `/setup`을 다시 실행해주세요.',
|
|
||||||
defaultCategoryName: '음성 채널',
|
|
||||||
defaultGeneratorName: '➕ 채널 생성하기',
|
|
||||||
auditCategories: {
|
|
||||||
SYSTEM: '시스템',
|
|
||||||
BOOT: '부팅',
|
|
||||||
VOICE: '음성',
|
|
||||||
PERMISSION: '권한',
|
|
||||||
INVITE: '초대',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
title: '기능 설정 변경 결과',
|
|
||||||
noOptions: '변경할 옵션을 하나 이상 선택해주세요.',
|
|
||||||
mimic: {
|
|
||||||
label: '미믹(Mimic)',
|
|
||||||
enabled: '활성화',
|
|
||||||
disabled: '비활성화',
|
|
||||||
},
|
|
||||||
emoji: {
|
|
||||||
label: '이모지 확대(Big Emoji)',
|
|
||||||
enabled: '활성화',
|
|
||||||
disabled: '비활성화',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 모달 ────────────────────────────────────────────────
|
// ── 모달 ────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -77,18 +77,6 @@ export interface TranslationSchema {
|
||||||
setSuccess: string;
|
setSuccess: string;
|
||||||
createSuccess: string;
|
createSuccess: string;
|
||||||
};
|
};
|
||||||
voiceConfig: {
|
|
||||||
description: string;
|
|
||||||
setNameTitle: string;
|
|
||||||
setNameDesc: string;
|
|
||||||
setLimitTitle: string;
|
|
||||||
setLimitDesc: string;
|
|
||||||
statusTitle: string;
|
|
||||||
templateLabel: string;
|
|
||||||
limitLabel: string;
|
|
||||||
setSuccess: string;
|
|
||||||
limitValue: string;
|
|
||||||
};
|
|
||||||
language: {
|
language: {
|
||||||
description: string;
|
description: string;
|
||||||
scopeDescription: string;
|
scopeDescription: string;
|
||||||
|
|
@ -117,41 +105,6 @@ export interface TranslationSchema {
|
||||||
MIMIC_WEBHOOK: string;
|
MIMIC_WEBHOOK: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
setup: {
|
|
||||||
description: string;
|
|
||||||
step0: { title: string; desc: string; startBtn: string; };
|
|
||||||
step1: { title: string; desc: string; placeholder: string; nextBtn: string; skipBtn: string; };
|
|
||||||
step2: { title: string; descOk: string; descFail: string; recheckBtn: string; nextBtn: string; };
|
|
||||||
step3: { title: string; desc: string; placeholder: string; disableBtn: string; nextBtn: string; };
|
|
||||||
step4: { title: string; desc: string; nextBtn: string; };
|
|
||||||
step5: { title: string; desc: string; placeholder: string; autoBtn: string; skipBtn: string; nextBtn: string; };
|
|
||||||
step6: { title: string; desc: string; finishBtn: string; };
|
|
||||||
finished: string;
|
|
||||||
expired: string;
|
|
||||||
defaultCategoryName: string;
|
|
||||||
defaultGeneratorName: string;
|
|
||||||
auditCategories: {
|
|
||||||
SYSTEM: string;
|
|
||||||
BOOT: string;
|
|
||||||
VOICE: string;
|
|
||||||
PERMISSION: string;
|
|
||||||
INVITE: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
config: {
|
|
||||||
title: string;
|
|
||||||
noOptions: string;
|
|
||||||
mimic: {
|
|
||||||
label: string;
|
|
||||||
enabled: string;
|
|
||||||
disabled: string;
|
|
||||||
};
|
|
||||||
emoji: {
|
|
||||||
label: string;
|
|
||||||
enabled: string;
|
|
||||||
disabled: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Modals ──
|
// ── Modals ──
|
||||||
|
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
import { MessageComponentInteraction, PermissionFlagsBits, ChannelType } from 'discord.js';
|
|
||||||
import { SetupWizardRenderer } from '../../services/SetupWizardRenderer';
|
|
||||||
import { SupportedLocale, t } from '../../i18n';
|
|
||||||
import { ErrorDefs, createBotError } from '../../errors/ErrorCodes';
|
|
||||||
import { prisma } from '../../database';
|
|
||||||
|
|
||||||
export async function handleSetupWizardInteraction(interaction: MessageComponentInteraction, locale: SupportedLocale) {
|
|
||||||
const customId = interaction.customId;
|
|
||||||
|
|
||||||
// Validate admin permission
|
|
||||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) && !interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) {
|
|
||||||
throw createBotError(ErrorDefs.USER_NOT_ADMIN);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle finishes & expiration
|
|
||||||
if (customId === 'setup_finish') {
|
|
||||||
await interaction.update({
|
|
||||||
content: t(locale, 'commands.setup.finished'),
|
|
||||||
embeds: [],
|
|
||||||
components: [],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next/Refresh Button Routing
|
|
||||||
if (customId.startsWith('setup_next_') || customId.startsWith('setup_refresh_')) {
|
|
||||||
const stepMatch = customId.match(/setup_(next|refresh)_(\d+)/);
|
|
||||||
if (!stepMatch) return;
|
|
||||||
const targetStep = parseInt(stepMatch[2], 10);
|
|
||||||
|
|
||||||
const { embed, components } = await SetupWizardRenderer.renderStep(targetStep, interaction, locale);
|
|
||||||
await interaction.update({ embeds: [embed], components });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4 Toggle: Audit Category
|
|
||||||
if (customId.startsWith('setup_audit_toggle_')) {
|
|
||||||
const category = customId.replace('setup_audit_toggle_', '');
|
|
||||||
const audit = await prisma.auditChannel.findUnique({ where: { guildId: interaction.guildId! } });
|
|
||||||
if (!audit) return;
|
|
||||||
|
|
||||||
let disabled = [...audit.disabledCategories];
|
|
||||||
if (disabled.includes(category)) {
|
|
||||||
disabled = disabled.filter(c => c !== category);
|
|
||||||
} else {
|
|
||||||
disabled.push(category);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.auditChannel.update({
|
|
||||||
where: { guildId: interaction.guildId! },
|
|
||||||
data: { disabledCategories: disabled }
|
|
||||||
});
|
|
||||||
|
|
||||||
const { embed, components } = await SetupWizardRenderer.renderStep(4, interaction, locale);
|
|
||||||
await interaction.update({ embeds: [embed], components });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Language Select
|
|
||||||
if (customId === 'setup_lang_select' && interaction.isStringSelectMenu()) {
|
|
||||||
const selectedLocale = interaction.values[0] as SupportedLocale;
|
|
||||||
await prisma.guildConfig.upsert({
|
|
||||||
where: { guildId: interaction.guildId! },
|
|
||||||
update: { locale: selectedLocale },
|
|
||||||
create: { guildId: interaction.guildId!, locale: selectedLocale }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render the next step immediately using the new locale
|
|
||||||
const { embed, components } = await SetupWizardRenderer.renderStep(2, interaction, selectedLocale);
|
|
||||||
await interaction.update({ embeds: [embed], components });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Audit Channel Select / Disable
|
|
||||||
if (customId === 'setup_audit_select' && interaction.isChannelSelectMenu()) {
|
|
||||||
const channelId = interaction.values[0];
|
|
||||||
await prisma.auditChannel.upsert({
|
|
||||||
where: { guildId: interaction.guildId! },
|
|
||||||
update: { channelId },
|
|
||||||
create: { guildId: interaction.guildId!, channelId }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto proceed to next step (Step 4: Categories)
|
|
||||||
const { embed, components } = await SetupWizardRenderer.renderStep(4, interaction, locale);
|
|
||||||
await interaction.update({ embeds: [embed], components });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (customId === 'setup_audit_disable') {
|
|
||||||
await prisma.auditChannel.delete({ where: { guildId: interaction.guildId! } }).catch(() => {});
|
|
||||||
// Skip categories if disabled, go to Step 5: Voice Setup
|
|
||||||
const { embed, components } = await SetupWizardRenderer.renderStep(5, interaction, locale);
|
|
||||||
await interaction.update({ embeds: [embed], components });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Voice Generator Select / Auto / Disable
|
|
||||||
if (customId === 'setup_voice_select' && interaction.isChannelSelectMenu()) {
|
|
||||||
const channelId = interaction.values[0];
|
|
||||||
|
|
||||||
const channel = await interaction.guild?.channels.fetch(channelId);
|
|
||||||
if (!channel || channel.type !== ChannelType.GuildVoice) {
|
|
||||||
throw createBotError(ErrorDefs.INVALID_CHANNEL_NAME); // fallback error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since channelId is the PK, upsert or deleteMany+create
|
|
||||||
const existing = await prisma.voiceGenerator.findFirst({ where: { guildId: interaction.guildId! } });
|
|
||||||
if (existing && existing.channelId !== channelId) {
|
|
||||||
await prisma.voiceGenerator.delete({ where: { channelId: existing.channelId } }).catch(() => {});
|
|
||||||
}
|
|
||||||
await prisma.voiceGenerator.upsert({
|
|
||||||
where: { channelId: channel.id },
|
|
||||||
update: { guildId: interaction.guildId!, categoryId: channel.parentId },
|
|
||||||
create: { channelId: channel.id, guildId: interaction.guildId!, categoryId: channel.parentId }
|
|
||||||
});
|
|
||||||
|
|
||||||
const { embed, components } = await SetupWizardRenderer.renderStep(6, interaction, locale);
|
|
||||||
await interaction.update({ embeds: [embed], components });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customId === 'setup_voice_auto') {
|
|
||||||
if (!interaction.guild) return;
|
|
||||||
try {
|
|
||||||
// Defer update because creating channels takes time
|
|
||||||
await interaction.deferUpdate();
|
|
||||||
|
|
||||||
const newCategory = await interaction.guild.channels.create({
|
|
||||||
name: t(locale, 'commands.setup.defaultCategoryName'),
|
|
||||||
type: ChannelType.GuildCategory,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newChannel = await interaction.guild.channels.create({
|
|
||||||
name: t(locale, 'commands.setup.defaultGeneratorName'),
|
|
||||||
type: ChannelType.GuildVoice,
|
|
||||||
parent: newCategory.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const existing = await prisma.voiceGenerator.findFirst({ where: { guildId: interaction.guildId! } });
|
|
||||||
if (existing) {
|
|
||||||
await prisma.voiceGenerator.delete({ where: { channelId: existing.channelId } }).catch(() => {});
|
|
||||||
}
|
|
||||||
await prisma.voiceGenerator.create({
|
|
||||||
data: {
|
|
||||||
channelId: newChannel.id,
|
|
||||||
guildId: interaction.guildId!,
|
|
||||||
categoryId: newCategory.id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { embed, components } = await SetupWizardRenderer.renderStep(6, interaction, locale);
|
|
||||||
await interaction.editReply({ embeds: [embed], components });
|
|
||||||
} catch (e) {
|
|
||||||
if ((e as Error).message.includes('Missing Permissions')) {
|
|
||||||
throw createBotError(ErrorDefs.BOT_MISSING_MANAGE_CHANNELS, e as Error);
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customId === 'setup_voice_disable') {
|
|
||||||
await prisma.voiceGenerator.deleteMany({ where: { guildId: interaction.guildId! } });
|
|
||||||
const { embed, components } = await SetupWizardRenderer.renderStep(6, interaction, locale);
|
|
||||||
await interaction.update({ embeds: [embed], components });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { Guild, EmbedBuilder, TextChannel, Colors } from 'discord.js';
|
import { Guild, EmbedBuilder, TextChannel, Colors } from 'discord.js';
|
||||||
import { prisma } from '../database';
|
import { prisma } from '../database';
|
||||||
import { env } from '../config/env';
|
|
||||||
|
|
||||||
export type AuditSeverity = 'INFO' | 'WARN' | 'ERROR';
|
export type AuditSeverity = 'INFO' | 'WARN' | 'ERROR';
|
||||||
export type AuditCategory = 'SYSTEM' | 'BOOT' | 'VOICE' | 'PERMISSION' | 'INVITE' | 'MIMIC';
|
export type AuditCategory = 'SYSTEM' | 'VOICE' | 'PERMISSION' | 'INVITE' | 'MIMIC';
|
||||||
|
|
||||||
export interface AuditLogPayload {
|
export interface AuditLogPayload {
|
||||||
category: AuditCategory;
|
category: AuditCategory;
|
||||||
|
|
@ -48,7 +47,7 @@ export class AuditLogService {
|
||||||
.setDescription(`**${payload.title}**\n\n${payload.description}`)
|
.setDescription(`**${payload.title}**\n\n${payload.description}`)
|
||||||
.setColor(color)
|
.setColor(color)
|
||||||
.setTimestamp()
|
.setTimestamp()
|
||||||
.setFooter({ text: `${icon} ${payload.severity} · Kord System [${env.INSTANCE_ID}]` });
|
.setFooter({ text: `${icon} ${payload.severity} · Kord System` });
|
||||||
|
|
||||||
if (payload.fields && payload.fields.length > 0) {
|
if (payload.fields && payload.fields.length > 0) {
|
||||||
embed.addFields(payload.fields);
|
embed.addFields(payload.fields);
|
||||||
|
|
@ -71,12 +70,7 @@ export class AuditLogService {
|
||||||
async setChannel(guildId: string, channelId: string): Promise<void> {
|
async setChannel(guildId: string, channelId: string): Promise<void> {
|
||||||
await prisma.auditChannel.upsert({
|
await prisma.auditChannel.upsert({
|
||||||
where: { guildId },
|
where: { guildId },
|
||||||
create: {
|
create: { guildId, channelId },
|
||||||
guildId,
|
|
||||||
channelId,
|
|
||||||
// 기본적으로 부팅 로그(BOOT)와 시스템 로그(SYSTEM)는 받지 않도록 설정
|
|
||||||
disabledCategories: ['BOOT', 'SYSTEM']
|
|
||||||
},
|
|
||||||
update: { channelId },
|
update: { channelId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { Message, TextChannel, PermissionFlagsBits } from 'discord.js';
|
|
||||||
import { WebhookService } from './WebhookService';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
|
|
||||||
export class BigEmojiService {
|
|
||||||
public static async handleMessage(message: Message) {
|
|
||||||
if (message.author.bot) return;
|
|
||||||
if (!(message.channel instanceof TextChannel)) return;
|
|
||||||
|
|
||||||
const content = message.content;
|
|
||||||
|
|
||||||
// Check if message is exactly one custom discord emoji
|
|
||||||
const customEmojiRegex = /^<a?:.+:(\d+)>$/i;
|
|
||||||
const match = content.match(customEmojiRegex);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const emojiId = match[1];
|
|
||||||
const isAnimated = content.startsWith('<a:');
|
|
||||||
const ext = isAnimated ? 'gif' : 'png';
|
|
||||||
const emojiUrl = `https://cdn.discordapp.com/emojis/${emojiId}.${ext}?size=256`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const me = message.guild?.members.me;
|
|
||||||
if (!me?.permissionsIn(message.channel).has(PermissionFlagsBits.ManageWebhooks)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const webhookClient = await WebhookService.getWebhookClient(message.channel);
|
|
||||||
if (webhookClient) {
|
|
||||||
await webhookClient.send({
|
|
||||||
content: emojiUrl,
|
|
||||||
username: message.member?.displayName || message.author.username,
|
|
||||||
avatarURL: message.author.displayAvatarURL(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (message.deletable) {
|
|
||||||
await message.delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`BigEmojiService Error:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,27 +10,48 @@ export class MimicService {
|
||||||
let content = message.content;
|
let content = message.content;
|
||||||
let modified = false;
|
let modified = false;
|
||||||
|
|
||||||
// Feature: Word Mimic
|
// Feature 1: Big Emoji
|
||||||
if (content.toLowerCase().includes('kord')) {
|
// If message is exactly one custom discord emoji, we enlarge it.
|
||||||
|
const customEmojiRegex = /^<a?:.+:(\d+)>$/i;
|
||||||
|
const match = content.match(customEmojiRegex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const emojiId = match[1];
|
||||||
|
const isAnimated = content.startsWith('<a:');
|
||||||
|
const ext = isAnimated ? 'gif' : 'png';
|
||||||
|
const emojiUrl = `https://cdn.discordapp.com/emojis/${emojiId}.${ext}?size=256`;
|
||||||
|
|
||||||
|
// Replace the emoji string with its raw image URL
|
||||||
|
content = emojiUrl;
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature 2: Prank / Word Mimic
|
||||||
|
// Example logic replacing a keyword to alter user message
|
||||||
|
if (content.includes('kord')) {
|
||||||
content = content.replace(/kord/gi, 'Kord(최고존엄)');
|
content = content.replace(/kord/gi, 'Kord(최고존엄)');
|
||||||
modified = true;
|
modified = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modified) {
|
if (modified) {
|
||||||
try {
|
try {
|
||||||
|
// Ensure we have permissions to manage webhooks and messages
|
||||||
const me = message.guild?.members.me;
|
const me = message.guild?.members.me;
|
||||||
if (!me?.permissionsIn(message.channel).has(PermissionFlagsBits.ManageWebhooks)) {
|
if (!me?.permissionsIn(message.channel).has(PermissionFlagsBits.ManageWebhooks)) {
|
||||||
return;
|
logger.warn(`Missing ManageWebhooks in ${message.channel.id}`);
|
||||||
|
return; // Can't send mimic
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookClient = await WebhookService.getWebhookClient(message.channel);
|
const webhookClient = await WebhookService.getWebhookClient(message.channel);
|
||||||
if (webhookClient) {
|
if (webhookClient) {
|
||||||
|
// Send modified message copying the user's name and avatar
|
||||||
await webhookClient.send({
|
await webhookClient.send({
|
||||||
content,
|
content,
|
||||||
username: message.member?.displayName || message.author.username,
|
username: message.member?.displayName || message.author.username,
|
||||||
avatarURL: message.author.displayAvatarURL(),
|
avatarURL: message.author.displayAvatarURL(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete the original message silently
|
||||||
if (message.deletable) {
|
if (message.deletable) {
|
||||||
await message.delete();
|
await message.delete();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
import {
|
|
||||||
EmbedBuilder,
|
|
||||||
ActionRowBuilder,
|
|
||||||
ButtonBuilder,
|
|
||||||
ButtonStyle,
|
|
||||||
StringSelectMenuBuilder,
|
|
||||||
ChannelSelectMenuBuilder,
|
|
||||||
ChannelType,
|
|
||||||
MessageComponentInteraction,
|
|
||||||
ChatInputCommandInteraction,
|
|
||||||
Colors,
|
|
||||||
} from 'discord.js';
|
|
||||||
import { SupportedLocale, t } from '../i18n';
|
|
||||||
import { PermissionAuditService } from './PermissionAuditService';
|
|
||||||
import { prisma } from '../database';
|
|
||||||
|
|
||||||
export class SetupWizardRenderer {
|
|
||||||
static async renderStep(
|
|
||||||
step: number,
|
|
||||||
interaction: MessageComponentInteraction | ChatInputCommandInteraction,
|
|
||||||
locale: SupportedLocale
|
|
||||||
): Promise<{ embed: EmbedBuilder; components: ActionRowBuilder<any>[] }> {
|
|
||||||
const embed = new EmbedBuilder().setColor(Colors.Blurple);
|
|
||||||
const components: ActionRowBuilder<any>[] = [];
|
|
||||||
|
|
||||||
switch (step) {
|
|
||||||
case 0: {
|
|
||||||
embed.setTitle(t(locale, 'commands.setup.step0.title'))
|
|
||||||
.setDescription(t(locale, 'commands.setup.step0.desc'));
|
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('setup_next_1')
|
|
||||||
.setLabel(t(locale, 'commands.setup.step0.startBtn'))
|
|
||||||
.setStyle(ButtonStyle.Primary)
|
|
||||||
);
|
|
||||||
components.push(row);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 1: {
|
|
||||||
embed.setTitle(t(locale, 'commands.setup.step1.title'))
|
|
||||||
.setDescription(t(locale, 'commands.setup.step1.desc', { locale: locale === 'ko' ? 'Korean' : 'English' }));
|
|
||||||
|
|
||||||
const select = new StringSelectMenuBuilder()
|
|
||||||
.setCustomId('setup_lang_select')
|
|
||||||
.setPlaceholder(t(locale, 'commands.setup.step1.placeholder'))
|
|
||||||
.addOptions([
|
|
||||||
{ label: 'Korean', value: 'ko', description: '한국어로 봇을 설정합니다.' },
|
|
||||||
{ label: 'English', value: 'en', description: 'Set bot to English.' }
|
|
||||||
]);
|
|
||||||
|
|
||||||
const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('setup_next_2')
|
|
||||||
.setLabel(t(locale, 'commands.setup.step1.nextBtn'))
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
);
|
|
||||||
|
|
||||||
components.push(new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select));
|
|
||||||
components.push(btnRow);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 2: {
|
|
||||||
if (!interaction.guild) throw new Error('Guild not found');
|
|
||||||
const results = await PermissionAuditService.auditGuild(interaction.guild);
|
|
||||||
const hasFails = results.some(r => r.status === 'FAIL');
|
|
||||||
|
|
||||||
embed.setTitle(t(locale, 'commands.setup.step2.title'));
|
|
||||||
if (hasFails) {
|
|
||||||
embed.setDescription(t(locale, 'commands.setup.step2.descFail'))
|
|
||||||
.setColor(Colors.Red);
|
|
||||||
// ⚠️ 권한 부족 목록 렌더링
|
|
||||||
const failLines = results
|
|
||||||
.filter(r => r.status === 'FAIL')
|
|
||||||
.map(r => `- **${t(locale, `commands.permissionAudit.features.${r.featureKey as any}`) || r.featureKey}**\n > \`${r.missingPermissions.join('`, `')}\``);
|
|
||||||
if (failLines.length > 0) {
|
|
||||||
embed.addFields({ name: 'Missing Permissions', value: failLines.join('\n') });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
embed.setDescription(t(locale, 'commands.setup.step2.descOk'))
|
|
||||||
.setColor(Colors.Green);
|
|
||||||
}
|
|
||||||
|
|
||||||
const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('setup_refresh_2')
|
|
||||||
.setLabel(t(locale, 'commands.setup.step2.recheckBtn'))
|
|
||||||
.setStyle(ButtonStyle.Secondary),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('setup_next_3')
|
|
||||||
.setLabel(t(locale, 'commands.setup.step2.nextBtn'))
|
|
||||||
.setStyle(hasFails ? ButtonStyle.Danger : ButtonStyle.Primary)
|
|
||||||
);
|
|
||||||
|
|
||||||
components.push(btnRow);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 3: {
|
|
||||||
embed.setTitle(t(locale, 'commands.setup.step3.title'))
|
|
||||||
.setDescription(t(locale, 'commands.setup.step3.desc'));
|
|
||||||
|
|
||||||
const select = new ChannelSelectMenuBuilder()
|
|
||||||
.setCustomId('setup_audit_select')
|
|
||||||
.setPlaceholder(t(locale, 'commands.setup.step3.placeholder'))
|
|
||||||
.setChannelTypes(ChannelType.GuildText);
|
|
||||||
|
|
||||||
const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('setup_audit_disable')
|
|
||||||
.setLabel(t(locale, 'commands.setup.step3.disableBtn'))
|
|
||||||
.setStyle(ButtonStyle.Danger),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('setup_next_4')
|
|
||||||
.setLabel(t(locale, 'commands.setup.step3.nextBtn'))
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
);
|
|
||||||
|
|
||||||
components.push(new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(select));
|
|
||||||
components.push(btnRow);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 4: {
|
|
||||||
const audit = await prisma.auditChannel.findUnique({ where: { guildId: interaction.guildId! } });
|
|
||||||
const disabled = audit?.disabledCategories || [];
|
|
||||||
|
|
||||||
embed.setTitle(t(locale, 'commands.setup.step4.title'))
|
|
||||||
.setDescription(t(locale, 'commands.setup.step4.desc'));
|
|
||||||
|
|
||||||
const categories: ('BOOT' | 'SYSTEM' | 'VOICE' | 'PERMISSION' | 'INVITE')[] = ['BOOT', 'SYSTEM', 'VOICE', 'PERMISSION', 'INVITE'];
|
|
||||||
const row1 = new ActionRowBuilder<ButtonBuilder>();
|
|
||||||
|
|
||||||
categories.forEach(cat => {
|
|
||||||
const isEnabled = !disabled.includes(cat);
|
|
||||||
row1.addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId(`setup_audit_toggle_${cat}`)
|
|
||||||
.setLabel(t(locale, `commands.setup.auditCategories.${cat}`))
|
|
||||||
.setStyle(isEnabled ? ButtonStyle.Success : ButtonStyle.Danger)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const row2 = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('setup_next_5')
|
|
||||||
.setLabel(t(locale, 'commands.setup.step4.nextBtn'))
|
|
||||||
.setStyle(ButtonStyle.Primary)
|
|
||||||
);
|
|
||||||
|
|
||||||
components.push(row1, row2);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 5: {
|
|
||||||
embed.setTitle(t(locale, 'commands.setup.step5.title'))
|
|
||||||
.setDescription(t(locale, 'commands.setup.step5.desc'));
|
|
||||||
|
|
||||||
const select = new ChannelSelectMenuBuilder()
|
|
||||||
.setCustomId('setup_voice_select')
|
|
||||||
.setPlaceholder(t(locale, 'commands.setup.step5.placeholder'))
|
|
||||||
.setChannelTypes(ChannelType.GuildVoice);
|
|
||||||
|
|
||||||
const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('setup_voice_auto')
|
|
||||||
.setLabel(t(locale, 'commands.setup.step5.autoBtn'))
|
|
||||||
.setStyle(ButtonStyle.Success),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('setup_voice_disable')
|
|
||||||
.setLabel(t(locale, 'commands.setup.step5.skipBtn'))
|
|
||||||
.setStyle(ButtonStyle.Danger),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('setup_next_6')
|
|
||||||
.setLabel(t(locale, 'commands.setup.step5.nextBtn'))
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
);
|
|
||||||
|
|
||||||
components.push(new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(select));
|
|
||||||
components.push(btnRow);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 6: {
|
|
||||||
if (!interaction.guild) throw new Error('Guild not found');
|
|
||||||
|
|
||||||
const config = await prisma.guildConfig.findUnique({ where: { guildId: interaction.guild.id } });
|
|
||||||
const audit = await prisma.auditChannel.findUnique({ where: { guildId: interaction.guild.id } });
|
|
||||||
const voice = await prisma.voiceGenerator.findFirst({ where: { guildId: interaction.guild.id } });
|
|
||||||
|
|
||||||
embed.setTitle(t(locale, 'commands.setup.step6.title'))
|
|
||||||
.setColor(Colors.Green);
|
|
||||||
|
|
||||||
const langStr = config?.locale === 'ko' ? 'Korean' : 'English';
|
|
||||||
const auditStr = audit?.channelId ? `<#${audit.channelId}>` : 'Disabled';
|
|
||||||
const voiceStr = voice?.channelId ? `<#${voice.channelId}>` : 'Disabled';
|
|
||||||
|
|
||||||
// 감사 로그 카테고리 요약
|
|
||||||
let catStr = 'None';
|
|
||||||
if (audit?.channelId) {
|
|
||||||
const allCats: ('BOOT' | 'SYSTEM' | 'VOICE' | 'PERMISSION' | 'INVITE')[] = ['BOOT', 'SYSTEM', 'VOICE', 'PERMISSION', 'INVITE'];
|
|
||||||
const enabled = allCats.filter(c => !audit.disabledCategories.includes(c));
|
|
||||||
catStr = enabled.map(c => t(locale, `commands.setup.auditCategories.${c}`)).join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
embed.setDescription(t(locale, 'commands.setup.step6.desc', {
|
|
||||||
lang: langStr,
|
|
||||||
audit: auditStr,
|
|
||||||
categories: catStr,
|
|
||||||
voice: voiceStr
|
|
||||||
}));
|
|
||||||
|
|
||||||
const btnRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('setup_finish')
|
|
||||||
.setLabel(t(locale, 'commands.setup.step6.finishBtn'))
|
|
||||||
.setStyle(ButtonStyle.Success)
|
|
||||||
);
|
|
||||||
|
|
||||||
components.push(btnRow);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { embed, components };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } from 'discord.js';
|
import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client } from 'discord.js';
|
||||||
import { prisma } from '../database';
|
import { prisma } from '../database';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { redis } from '../cache';
|
import { redis } from '../cache';
|
||||||
|
|
@ -134,29 +134,9 @@ export class VoiceService {
|
||||||
// Resolve locale for this context
|
// Resolve locale for this context
|
||||||
const locale = await getContextLocale(guild.id, member.id);
|
const locale = await getContextLocale(guild.id, member.id);
|
||||||
|
|
||||||
// Fetch guild-specific config
|
const profile = await prisma.userVoiceProfile.findUnique({ where: { userId: member.id }});
|
||||||
const guildConfig = await prisma.voiceGuildConfig.findUnique({ where: { guildId: guild.id } });
|
const channelName = profile?.customName || t(locale, 'voice.defaultRoomName', { username: member.user.username });
|
||||||
const profile = await prisma.userVoiceProfile.findUnique({
|
const userLimit = profile?.userLimit || 0;
|
||||||
where: { userId_guildId: { userId: member.id, guildId: guild.id } }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fallback logic for name resolution
|
|
||||||
const effectiveName = this.getEffectiveName(member);
|
|
||||||
|
|
||||||
// Naming priority: User Custom Profile > Guild Template > Default I18n
|
|
||||||
let channelName: string;
|
|
||||||
const profileName = profile?.customName;
|
|
||||||
if (profileName) {
|
|
||||||
channelName = profileName;
|
|
||||||
} else {
|
|
||||||
const template = guildConfig?.defaultNameTemplate || t(locale, 'voice.defaultRoomName');
|
|
||||||
channelName = template.replace('{{username}}', effectiveName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final safety fallback to avoid undefined
|
|
||||||
if (!channelName) channelName = `${effectiveName}'s Room`;
|
|
||||||
|
|
||||||
const userLimit = profile?.userLimit ?? guildConfig?.defaultUserLimit ?? 0;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parentId = generator.categoryId || state.channel?.parentId || undefined;
|
const parentId = generator.categoryId || state.channel?.parentId || undefined;
|
||||||
|
|
@ -291,9 +271,7 @@ export class VoiceService {
|
||||||
public static async applyOwnershipTransfer(channel: VoiceChannel, oldOwnerId: string, newOwnerId: string) {
|
public static async applyOwnershipTransfer(channel: VoiceChannel, oldOwnerId: string, newOwnerId: string) {
|
||||||
const locale = await getContextLocale(channel.guildId, newOwnerId);
|
const locale = await getContextLocale(channel.guildId, newOwnerId);
|
||||||
|
|
||||||
const profile = await prisma.userVoiceProfile.findUnique({
|
const profile = await prisma.userVoiceProfile.findUnique({ where: { userId: newOwnerId }});
|
||||||
where: { userId_guildId: { userId: newOwnerId, guildId: channel.guildId } }
|
|
||||||
});
|
|
||||||
const newMember = await channel.guild.members.fetch(newOwnerId);
|
const newMember = await channel.guild.members.fetch(newOwnerId);
|
||||||
const finalName = profile?.customName || t(locale, 'voice.defaultRoomName', { username: newMember.user.username });
|
const finalName = profile?.customName || t(locale, 'voice.defaultRoomName', { username: newMember.user.username });
|
||||||
|
|
||||||
|
|
@ -339,15 +317,4 @@ export class VoiceService {
|
||||||
logger.error('Failed to send control panel UI', e);
|
logger.error('Failed to send control panel UI', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the member's name based on hierarchy:
|
|
||||||
* 1. Server Nickname
|
|
||||||
* 2. Global Display Name
|
|
||||||
* 3. Username
|
|
||||||
* 4. User ID (Fallback)
|
|
||||||
*/
|
|
||||||
public static getEffectiveName(member: GuildMember): string {
|
|
||||||
return member.nickname || member.user.globalName || member.user.username || member.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,12 @@ describe('i18n Core', () => {
|
||||||
describe('t() - Translation Function', () => {
|
describe('t() - Translation Function', () => {
|
||||||
it('should return English translation for a valid key', () => {
|
it('should return English translation for a valid key', () => {
|
||||||
const result = t('en', 'voice.responses.channelLocked');
|
const result = t('en', 'voice.responses.channelLocked');
|
||||||
expect(result).toBe('Channel Locked! Only you and invited members can join.'); // i18n-ignore
|
expect(result).toBe('Channel Locked! Only you and invited members can join.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return Korean translation for a valid key', () => {
|
it('should return Korean translation for a valid key', () => {
|
||||||
const result = t('ko', 'voice.responses.channelLocked');
|
const result = t('ko', 'voice.responses.channelLocked');
|
||||||
expect(result).toBe('채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.'); // i18n-ignore
|
expect(result).toBe('채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fallback to English when key is missing in target locale', () => {
|
it('should fallback to English when key is missing in target locale', () => {
|
||||||
|
|
|
||||||
|
|
@ -8,27 +8,4 @@ describe('VoiceService Test Suite', () => {
|
||||||
await VoiceService.handleVoiceStateUpdate(mockState, mockState);
|
await VoiceService.handleVoiceStateUpdate(mockState, mockState);
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should resolve nickname correctly in getEffectiveName', () => {
|
|
||||||
const mockMember = {
|
|
||||||
id: '123',
|
|
||||||
nickname: 'ServerNick',
|
|
||||||
user: {
|
|
||||||
globalName: 'GlobalName',
|
|
||||||
username: 'UserBase',
|
|
||||||
id: '123'
|
|
||||||
}
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
expect(VoiceService.getEffectiveName(mockMember)).toBe('ServerNick');
|
|
||||||
|
|
||||||
mockMember.nickname = null;
|
|
||||||
expect(VoiceService.getEffectiveName(mockMember)).toBe('GlobalName');
|
|
||||||
|
|
||||||
mockMember.user.globalName = null;
|
|
||||||
expect(VoiceService.getEffectiveName(mockMember)).toBe('UserBase');
|
|
||||||
|
|
||||||
mockMember.user.username = null;
|
|
||||||
expect(VoiceService.getEffectiveName(mockMember)).toBe('123');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
44
yarn.lock
44
yarn.lock
|
|
@ -1734,6 +1734,28 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"Kord@workspace:.":
|
||||||
|
version: 0.0.0-use.local
|
||||||
|
resolution: "Kord@workspace:."
|
||||||
|
dependencies:
|
||||||
|
"@prisma/client": "npm:6.4.1"
|
||||||
|
"@types/jest": "npm:^30.0.0"
|
||||||
|
"@types/node": "npm:^25.5.0"
|
||||||
|
"@typescript-eslint/eslint-plugin": "npm:^8.57.2"
|
||||||
|
"@typescript-eslint/parser": "npm:^8.57.2"
|
||||||
|
discord.js: "npm:^14.25.1"
|
||||||
|
dotenv: "npm:^17.3.1"
|
||||||
|
eslint: "npm:^10.1.0"
|
||||||
|
ioredis: "npm:^5.10.1"
|
||||||
|
jest: "npm:^30.3.0"
|
||||||
|
prettier: "npm:^3.8.1"
|
||||||
|
prisma: "npm:6.4.1"
|
||||||
|
ts-jest: "npm:^29.4.6"
|
||||||
|
tsx: "npm:^4.21.0"
|
||||||
|
typescript: "npm:^6.0.2"
|
||||||
|
languageName: unknown
|
||||||
|
linkType: soft
|
||||||
|
|
||||||
"abbrev@npm:^4.0.0":
|
"abbrev@npm:^4.0.0":
|
||||||
version: 4.0.0
|
version: 4.0.0
|
||||||
resolution: "abbrev@npm:4.0.0"
|
resolution: "abbrev@npm:4.0.0"
|
||||||
|
|
@ -3605,28 +3627,6 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"kord@workspace:.":
|
|
||||||
version: 0.0.0-use.local
|
|
||||||
resolution: "kord@workspace:."
|
|
||||||
dependencies:
|
|
||||||
"@prisma/client": "npm:6.4.1"
|
|
||||||
"@types/jest": "npm:^30.0.0"
|
|
||||||
"@types/node": "npm:^25.5.0"
|
|
||||||
"@typescript-eslint/eslint-plugin": "npm:^8.57.2"
|
|
||||||
"@typescript-eslint/parser": "npm:^8.57.2"
|
|
||||||
discord.js: "npm:^14.25.1"
|
|
||||||
dotenv: "npm:^17.3.1"
|
|
||||||
eslint: "npm:^10.1.0"
|
|
||||||
ioredis: "npm:^5.10.1"
|
|
||||||
jest: "npm:^30.3.0"
|
|
||||||
prettier: "npm:^3.8.1"
|
|
||||||
prisma: "npm:6.4.1"
|
|
||||||
ts-jest: "npm:^29.4.6"
|
|
||||||
tsx: "npm:^4.21.0"
|
|
||||||
typescript: "npm:^6.0.2"
|
|
||||||
languageName: unknown
|
|
||||||
linkType: soft
|
|
||||||
|
|
||||||
"leven@npm:^3.1.0":
|
"leven@npm:^3.1.0":
|
||||||
version: 3.1.0
|
version: 3.1.0
|
||||||
resolution: "leven@npm:3.1.0"
|
resolution: "leven@npm:3.1.0"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue