Compare commits

...

10 Commits

Author SHA1 Message Date
이정수 7dd437c373 docs: update Kord agent routine rules. 2026-03-27 18:31:07 +09:00
이정수 5e41bea74e feat: Add instance ID and implement Redis lock for global command registration to support multi-instance deployments. 2026-03-27 18:29:46 +09:00
이정수 031a8b3146 refactor: Consolidate voice-related commands into `voice.ts` and audit-related commands into `audit.ts`. 2026-03-27 17:54:55 +09:00
이정수 0ccbbf9d31 feat: Introduce and separate `BOOT` audit log category from `SYSTEM`, defaulting both to disabled. 2026-03-27 17:53:00 +09:00
이정수 47dc4ab124 feat: Introduce a script to identify hardcoded i18n strings in test files, add a `check-i18n` command to package.json, and include ignore comments in i18n tests. 2026-03-27 17:38:07 +09:00
이정수 90064491d6 refactor: Relocate `config` translation keys and types under the `commands` namespace. 2026-03-27 17:24:03 +09:00
이정수 4246eb90a5 feat: Implement `/config` command for managing bot features, refactor Big Emoji into a dedicated service, and update guild configuration schema with new defaults. 2026-03-27 17:16:15 +09:00
이정수 b81bc6b146 feat: Add an audit log category selection step to the setup wizard, shifting subsequent steps and updating related logic and translations. 2026-03-27 16:56:37 +09:00
이정수 bdd91f6737 feat: Implement a multi-step setup wizard with i18n support and dedicated interaction handlers. 2026-03-27 15:57:50 +09:00
이정수 9f891112d9 feat: Introduce guild-specific configuration for temporary voice channels with a new command, updated service logic, and corresponding database schema changes. 2026-03-27 15:41:27 +09:00
48 changed files with 1856 additions and 481 deletions

View File

@ -1,30 +1,41 @@
--- ---
description: Documentation Workflow trigger: model_decision
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)**: 아직 정해지지 않았거나 미완성인 내용을 "향후 추가 예정"이라고 적어두는 것을 금합니다. (이는 결국 의미 없는 껍데기 문서가 되므로 작성을 지양합니다.)

View File

@ -1,36 +1,45 @@
--- ---
description: Kord 프로젝트 개발 및 테스트 작업 루틴 trigger: model_decision
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/`에 기록 및 정리된 후에만 비로소 "작업을 완료했다"고 사용자에게 알립니다.

View File

@ -1,6 +1,8 @@
--- ---
description: Security Rules (새 규칙) trigger: model_decision
description: security
--- ---
이 워크플로우는 프로젝트 진행 시 잊지 않고 준수해야 하는 새로운 보안 규칙을 정의합니다. 이 워크플로우는 프로젝트 진행 시 잊지 않고 준수해야 하는 새로운 보안 규칙을 정의합니다.
## 핵심 보안 규칙 (Core Security Rules) ## 핵심 보안 규칙 (Core Security Rules)

24
Docs/Design_Principles.md Normal file
View File

@ -0,0 +1,24 @@
# Kord 디자인 원칙 (Design Principles)
Kord 봇의 기능 설계 및 사용자 경험(UX) 고도화를 위한 핵심 원칙입니다.
## 1. 부가 기능의 기본 비활성화 (Opt-in by Default)
사용자의 서버 운영에 필수가 아닌 부가 기능(Fun features, 유틸리티 성격의 부가 기능 등)은 초기 도입 시 **기본적으로 비활성화(Disabled)** 상태여야 합니다.
- **이유**: 서버 관리자가 의도하지 않은 봇의 반응(메시지 치환, 자동 이모지 확대 등)으로 인한 혼선을 방지하기 위함입니다.
- **예시**: 미믹(Mimic), 이모지 확대(Big Emoji) 등.
## 2. 설정 도우미(Setup Wizard)의 간결성 유지
`/setup` 명령어를 통해 제공되는 설정 도우미는 서버 운영의 **핵심 필수 설정**에만 집중합니다.
- **포함 대상**: 언어 설정, 보안/감사 로그 채널, 필수 권한 점검, 핵심 서비스(임시 음성 채널 등) 진입점 설정.
- **제외 대상**: 미믹 활성화 여부, 이모지 확대 여부 등 부가적인 환경 설정.
- **설정 방법**: 설정 도우미에서 제외된 기능은 별도의 관리 명령어(예: `/config`)를 통해 개별적으로 활성화할 수 있도록 제공합니다.
## 3. 용어의 일관성 (Terminology)
기능의 명칭은 기술적 정의와 사용자 친화도 사이의 균형을 맞추며, 한 번 정해진 고유 명사는 일관되게 사용합니다.
- **예시**: 사용자의 메시지를 흉내 내는 기능은 '흉내'라는 일반 명사 대신 **'미믹(Mimic)'**이라는 고유 명사를 프로젝트 전반(코드, 번역, 문서)에서 사용합니다.

View File

@ -40,14 +40,14 @@
--- ---
### 2. 권한 검사 (Permission Audit) ### 2. 권한 검사 (Permission Audit)
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| **목표** | 봇이 각 기능을 수행하기 위해 충분한 권한을 가지고 있는지 진단하고 보고서를 생성 | | **목표** | 봇이 각 기능을 수행하기 위해 충분한 권한을 가지고 있는지 진단하고 보고서를 생성 |
| **트리거** | 슬래시 명령어 (`/audit-permissions` 등) | | **트리거** | 슬래시 명령어 (`/audit-permissions` 등) |
| **출력** | 실행한 채널에 Embed 형태의 권한 진단 보고서 전송 | | **출력** | 실행한 채널에 Embed 형태의 권한 진단 보고서 전송 |
| **기획서** | `Docs/Plans/Permission_Audit_Plan.md` *(미작성)* | | **기획서** | [`Permission_Audit_Plan.md`](./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 조합의 스텝 바이 스텝 인터랙션 |
| **기획서** | `Docs/Plans/Setup_Wizard_Plan.md` *(미작성)* | | **기획서** | [`Setup_Wizard_Plan.md`](./Setup_Wizard_Plan.md) |
**핵심 고려사항** **핵심 고려사항**
- 설정 항목 정의 (언어, 감사 채널, 임시 음성 채널 생성기 등) - 설정 항목 정의 (언어, 감사 채널, 임시 음성 채널 생성기 등)

View File

@ -0,0 +1,82 @@
# 봇 설정 도우미 (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) 오류 출력.

View File

@ -20,5 +20,12 @@ 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 필수 적용 원칙 수립 및 가이드라인 생성

View File

@ -0,0 +1,26 @@
# 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`) 완료.
- 채널 권한 부족 시의 적절한 사용자 안내 메시지 출력 확인.

View File

@ -0,0 +1,25 @@
# 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) 등록 확인 완료.

View File

@ -0,0 +1,21 @@
# 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/*) 모두 통과 확인.

View File

@ -0,0 +1,22 @@
# 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 수준의 권한 점검 로직의 정확성 테스트 완료.

View File

@ -0,0 +1,26 @@
# 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**: 초기 에러 핸들링 구조 도입.
## 기여 및 결과
- 디스코드 봇 기본 구동 확인.
- 슬래시 명령어 자동 등록 및 인터랙션 핸들링 성공.
- 데이터베이스 연동 및 기본 모델링 완료.

View File

@ -0,0 +1,25 @@
# 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`를 통해 닉네임 결정 로직이 의도한 우선순위대로 작동함을 확인했습니다.
- **수동 테스트**: 서로 다른 서버에서 한 유저에게 다른 프로필이 각각 독립적으로 저장되고 적용됨을 확인했습니다.

View File

@ -0,0 +1,31 @@
# 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 파이프라인에 해당 검사 단계를 추가하여 코드 품질을 유지할 예정입니다.

View File

@ -2,14 +2,20 @@
이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다. 이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다.
## 정책 및 규칙 (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)
@ -17,15 +23,27 @@
- [에러 안내 기능 기획서 (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)

View File

@ -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,6 +24,7 @@
"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"
} }
} }

View File

@ -0,0 +1,10 @@
-- 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")
);

View File

@ -0,0 +1,11 @@
/*
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");

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "GuildConfig" ADD COLUMN "bigEmojiEnabled" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "mimicEnabled" SET DEFAULT false;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "AuditChannel" ALTER COLUMN "disabledCategories" SET DEFAULT ARRAY['SYSTEM']::TEXT[];

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "AuditChannel" ALTER COLUMN "disabledCategories" SET DEFAULT ARRAY['BOOT', 'SYSTEM']::TEXT[];

View File

@ -10,8 +10,9 @@ datasource db {
model GuildConfig { model GuildConfig {
guildId String @id guildId String @id
prefix String @default("!") prefix String @default("!")
mimicEnabled Boolean @default(true) mimicEnabled Boolean @default(false)
locale String? bigEmojiEnabled Boolean @default(false)
locale String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@ -65,11 +66,14 @@ model TempVoiceChannel {
} }
model UserVoiceProfile { model UserVoiceProfile {
userId String @id userId String
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 {
@ -94,7 +98,15 @@ enum DeleteCondition {
model AuditChannel { model AuditChannel {
guildId String @id guildId String @id
channelId String channelId String
disabledCategories String[] @default([]) disabledCategories String[] @default(["BOOT", "SYSTEM"])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model VoiceGuildConfig {
guildId String @id
defaultNameTemplate String @default("{{username}}'s Room")
defaultUserLimit Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

125
scripts/check-i18n-tests.ts Normal file
View File

@ -0,0 +1,125 @@
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.`);
}

View File

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

237
src/commands/audit.ts Normal file
View File

@ -0,0 +1,237 @@
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 });
}
},
};

View File

@ -1,158 +0,0 @@
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)'}** 되었습니다.`,
});
}
},
};

View File

@ -1,120 +0,0 @@
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(() => {});
}
},
};

63
src/commands/config.ts Normal file
View File

@ -0,0 +1,63 @@
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 });
},
};

27
src/commands/setup.ts Normal file
View File

@ -0,0 +1,27 @@
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,
});
},
};

203
src/commands/voice.ts Normal file
View File

@ -0,0 +1,203 @@
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
});
}
},
};

View File

@ -1,111 +0,0 @@
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
});
}
},
};

View File

@ -1,6 +1,11 @@
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 || '',
@ -10,4 +15,5 @@ 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(),
}; };

View File

@ -7,6 +7,7 @@ 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,
@ -21,6 +22,12 @@ 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;
@ -139,9 +146,9 @@ export default {
await voiceChannel.setName(newName); await voiceChannel.setName(newName);
await prisma.userVoiceProfile.upsert({ await prisma.userVoiceProfile.upsert({
where: { userId: ownerId }, where: { userId_guildId: { userId: ownerId, guildId: interaction.guildId! } },
update: { customName: newName }, update: { customName: newName },
create: { userId: ownerId, customName: newName } create: { userId: ownerId, guildId: interaction.guildId!, 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 });
@ -155,9 +162,9 @@ export default {
await voiceChannel.setUserLimit(limit); await voiceChannel.setUserLimit(limit);
await prisma.userVoiceProfile.upsert({ await prisma.userVoiceProfile.upsert({
where: { userId: ownerId }, where: { userId_guildId: { userId: ownerId, guildId: interaction.guildId! } },
update: { userLimit: limit }, update: { userLimit: limit },
create: { userId: ownerId, userLimit: limit } create: { userId: ownerId, guildId: interaction.guildId!, userLimit: limit }
}); });
const limitDisplay = limit === 0 ? t(locale, 'voice.responses.limitUnlimited') : String(limit); const limitDisplay = limit === 0 ? t(locale, 'voice.responses.limitUnlimited') : String(limit);

View File

@ -1,10 +1,24 @@
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) {
await MimicService.handleMessage(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);
}
}, },
}; };

View File

@ -5,6 +5,8 @@ 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,
@ -16,19 +18,27 @@ export default {
PresenceService.startActivePresence(client); PresenceService.startActivePresence(client);
try { try {
const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON()); const lockKey = 'commands:sync:lock';
await client.application?.commands.set(commandsData); // EX 300 = 5 minutes lock. Only one instance needs to do this per boot cycle.
logger.info(`Successfully registered ${commandsData.length} global application commands.`); const acquired = await redis.set(lockKey, '1', 'EX', 300, 'NX');
if (acquired) {
const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON());
await client.application?.commands.set(commandsData);
logger.info(`Successfully registered ${commandsData.length} global application commands.`);
} 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: 'SYSTEM', category: 'BOOT',
severity: 'INFO', severity: 'INFO',
title: 'Bot Online', title: 'Bot Online',
description: `Kord has successfully started or reconnected.` description: `Kord instance **[${env.INSTANCE_ID}]** has successfully started or reconnected.`
}).catch(() => {}); }).catch(() => {});
}); });
}, },

View File

@ -11,12 +11,18 @@ 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);
const command = require(filePath).default; try {
if (command && 'data' in command && 'execute' in command) { const module = require(filePath);
client.commands.set(command.data.name, command); const command = module.default || module;
logger.debug(`Loaded command: ${command.data.name}`);
} else { if (command && 'data' in command && 'execute' in command) {
logger.warn(`The command at ${filePath} is missing a required "data" or "execute" property.`); client.commands.set(command.data.name, command);
logger.debug(`Loaded command: ${command.data.name}`);
} else {
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);
} }
} }
}; };

View File

@ -123,6 +123,18 @@ 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',
@ -151,6 +163,78 @@ 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 ──────────────────────────────────────────────

View File

@ -123,6 +123,18 @@ 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: '본인에게만 또는 서버 전체에 적용',
@ -151,6 +163,78 @@ 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: '비활성화',
},
},
}, },
// ── 모달 ──────────────────────────────────────────────── // ── 모달 ────────────────────────────────────────────────

View File

@ -77,6 +77,18 @@ 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;
@ -105,6 +117,41 @@ 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 ──

View File

@ -0,0 +1,167 @@
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;
}
}

View File

@ -1,8 +1,9 @@
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' | 'VOICE' | 'PERMISSION' | 'INVITE' | 'MIMIC'; export type AuditCategory = 'SYSTEM' | 'BOOT' | 'VOICE' | 'PERMISSION' | 'INVITE' | 'MIMIC';
export interface AuditLogPayload { export interface AuditLogPayload {
category: AuditCategory; category: AuditCategory;
@ -47,7 +48,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` }); .setFooter({ text: `${icon} ${payload.severity} · Kord System [${env.INSTANCE_ID}]` });
if (payload.fields && payload.fields.length > 0) { if (payload.fields && payload.fields.length > 0) {
embed.addFields(payload.fields); embed.addFields(payload.fields);
@ -70,7 +71,12 @@ 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: { guildId, channelId }, create: {
guildId,
channelId,
// 기본적으로 부팅 로그(BOOT)와 시스템 로그(SYSTEM)는 받지 않도록 설정
disabledCategories: ['BOOT', 'SYSTEM']
},
update: { channelId }, update: { channelId },
}); });
} }

View File

@ -0,0 +1,45 @@
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);
}
}
}
}

View File

@ -10,48 +10,27 @@ export class MimicService {
let content = message.content; let content = message.content;
let modified = false; let modified = false;
// Feature 1: Big Emoji // Feature: Word Mimic
// If message is exactly one custom discord emoji, we enlarge it. if (content.toLowerCase().includes('kord')) {
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)) {
logger.warn(`Missing ManageWebhooks in ${message.channel.id}`); return;
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();
} }

View File

@ -0,0 +1,229 @@
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 };
}
}

View File

@ -1,4 +1,4 @@
import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client } from 'discord.js'; import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } 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,9 +134,29 @@ 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);
const profile = await prisma.userVoiceProfile.findUnique({ where: { userId: member.id }}); // Fetch guild-specific config
const channelName = profile?.customName || t(locale, 'voice.defaultRoomName', { username: member.user.username }); const guildConfig = await prisma.voiceGuildConfig.findUnique({ where: { guildId: guild.id } });
const userLimit = profile?.userLimit || 0; const profile = await prisma.userVoiceProfile.findUnique({
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;
@ -271,7 +291,9 @@ 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({ where: { userId: newOwnerId }}); const profile = await prisma.userVoiceProfile.findUnique({
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 });
@ -317,4 +339,15 @@ 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;
}
} }

View File

@ -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.'); expect(result).toBe('Channel Locked! Only you and invited members can join.'); // i18n-ignore
}); });
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('채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.'); expect(result).toBe('채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.'); // i18n-ignore
}); });
it('should fallback to English when key is missing in target locale', () => { it('should fallback to English when key is missing in target locale', () => {

View File

@ -8,4 +8,27 @@ 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');
});
}); });

View File

@ -1734,28 +1734,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
"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"
@ -3627,6 +3605,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
"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"