Compare commits
10 Commits
234a0e96fe
...
7dd437c373
| Author | SHA1 | Date |
|---|---|---|
|
|
7dd437c373 | |
|
|
5e41bea74e | |
|
|
031a8b3146 | |
|
|
0ccbbf9d31 | |
|
|
47dc4ab124 | |
|
|
90064491d6 | |
|
|
4246eb90a5 | |
|
|
b81bc6b146 | |
|
|
bdd91f6737 | |
|
|
9f891112d9 |
|
|
@ -1,30 +1,41 @@
|
|||
---
|
||||
description: Documentation Workflow
|
||||
trigger: model_decision
|
||||
description: documentation
|
||||
---
|
||||
|
||||
이 워크플로우는 코드를 작성하거나 수정하기 전, 중, 후에 반드시 따라야 하는 문서화 규칙을 정의합니다. (Dcos는 올바른 Docs 디렉토리로 간주하여 작성되었습니다.)
|
||||
|
||||
## 핵심 원칙 (Core Principles)
|
||||
|
||||
1. **문서화 위치 (Location)**: 모든 문서는 반드시 `<PROJECT_ROOT>/Docs/` 디렉토리 내에 작성되어야 합니다.
|
||||
2. **사전 탐색 (Search First)**: 작업을 시작하기 전에 `Docs/` 내의 관련 문서 유무를 먼저 탐색하고, 명시된 규칙/내용을 확인한 뒤 작업에 적용해야 합니다.
|
||||
|
||||
## 기록 대상 (What to Document)
|
||||
|
||||
프로젝트와 관련된 다음의 내용들은 반드시 문서화해야 합니다:
|
||||
|
||||
- 진행 및 완료한 작업 내역 (Work done)
|
||||
- 중요한 기술적/기획적 의사 결정 사항 (Decisions made)
|
||||
- 문제 해결 과정 및 트러블 슈팅 내역 (Troubleshooting)
|
||||
|
||||
## 색인 규칙 (Indexing Rules)
|
||||
|
||||
문서의 저장과 탐색을 원활하게 하기 위해 다음 규칙을 따릅니다:
|
||||
|
||||
1. **카테고리별 디렉토리 (Directory by Category)**: 문서는 카테고리별로 서브 디렉토리를 구성하여(예: `Docs/Troubleshooting/`, `Docs/Decisions/` 등) 저장합니다.
|
||||
2. **index.md 색인화 (Indexing)**: 새로운 문서를 추가하거나 기존 문서를 변경하면, 반드시 `Docs/index.md` 파일에 해당 문서를 색인(링크 추가)하여 탐색을 용이하게 해야 합니다.
|
||||
|
||||
## 문서 기본 템플릿 (Basic Template)
|
||||
|
||||
모든 문서는 다음의 기본 구조를 포함해야 합니다.
|
||||
|
||||
- **체인지로그 (Changelog)**: 이 문서의 수정 내역 또는 관련 코드의 변경 이력 요약
|
||||
- **본문 (Body)**: 상세 가이드, 문제 해결 과정, 의사 결정 배경 등 본 내용
|
||||
|
||||
## 금지 규칙 (Forbidden Rules)
|
||||
|
||||
문서를 작성할 때 **절대 금지**해야 하는 항목입니다:
|
||||
|
||||
- **중복 내용 (Duplicated Content)**: 다른 문서나 시스템에 이미 존재하는 내용을 반복해서 적지 마십시오.
|
||||
- **없는 내용 (Fabricated Content)**: 근거가 없거나 실제로 이루어지지 않은 내용을 작성하지 마십시오.
|
||||
- **향후 추가할 내용 (Placeholder Content)**: 아직 정해지지 않았거나 미완성인 내용을 "향후 추가 예정"이라고 적어두는 것을 금합니다. (이는 결국 의미 없는 껍데기 문서가 되므로 작성을 지양합니다.)
|
||||
|
|
@ -1,36 +1,45 @@
|
|||
---
|
||||
description: Kord 프로젝트 개발 및 테스트 작업 루틴
|
||||
trigger: model_decision
|
||||
description: work routine
|
||||
---
|
||||
|
||||
# Kord Discord Bot Development Routine & Rules
|
||||
|
||||
이 워크플로우는 Kord 프로젝트의 기능 개발 및 테스트 표준 절차를 정의합니다. Kord와 관련된 모든 작업 지시를 받을 때 다음 규칙과 절차를 반드시 따르십시오.
|
||||
|
||||
## 기본 원칙 (Work Rules)
|
||||
|
||||
1. **인프라 자율 사용**: 에이전트는 프로젝트에 설정된 Docker 기반 인프라(PostgreSQL, Redis 등)를 사용자의 추가 승인 없이 자유롭게 구동(`docker-compose up -d`) 및 활용할 수 있습니다.
|
||||
2. **협력적 기획, 독립적 실행**: 기능 기획과 설계(Architecture, Schema 등)는 사용자와 함께 논리적인 완결성을 갖출 때까지 충분히 논의합니다. 기획이 "완료 및 승인"된 후에는 후속 구현, 에러 디버깅, 자체 테스트를 추가적인 중간 확인 없이 에이전트가 주도를 가지고 끝마친 뒤 최종 결과를 보고합니다.
|
||||
|
||||
## 단계별 작업 루틴
|
||||
|
||||
### 1단계: 기획 및 설계 (Planning Phase)
|
||||
|
||||
- 사용자가 새로운 기능이나 수정 사항을 요청하면, 필요한 스펙 및 예외 사항을 꼼꼼히 확인합니다.
|
||||
- 변경 사항, 사용 스택, 아키텍처를 포함한 `implementation_plan.md`를 작성하거나 업데이트하여 사용자에게 검토 및 승인을 요청합니다.
|
||||
|
||||
### 2단계: 개발 및 구현 (Execution Phase)
|
||||
|
||||
- 설계가 최종 승인되면 실제 코딩을 시작합니다. 이 단계부터는 사용자의 개입 없이 독립적으로 작업을 완수하는 것을 원칙으로 합니다.
|
||||
- 환경 변수 파싱, 로깅, 예외 처리를 철저히 포함하여 프로덕션 수준의 코드를 작성합니다.
|
||||
|
||||
### 3단계: 자체 구동 및 내부 테스트 (Internal Testing Phase)
|
||||
|
||||
- 사용자가 최종 테스트를 하기 전에 **에이전트 스스로 봇이 모순이나 심각한 에러 없이 구동 가능한지 확인**해야 합니다.
|
||||
- 모든 시뮬레이션 및 테스트 구동 시 반드시 `yarn` 패키지 매니저를 사용합니다.
|
||||
- 동시 실행 시 충돌을 방지하기 위해, 테스트 구동 시에는 반드시 **임의의 고유한 `INSTANCE_ID` 환경 변수를 할당**(`INSTANCE_ID=agent-test-xxxx yarn run dev`)하여 기존 인스턴스와 락(Lock)이나 로그가 겹치지 않게 합니다.
|
||||
- 필요하다면 단위 테스트(`yarn test`)를 실행합니다.
|
||||
- 인프라(DB/Cache)를 연결하고 로컬에서 봇을 시험 가동하여 TypeScript 컴파일 에러나 런타임 초기화 에러가 없는지 완벽하게 점검합니다.
|
||||
- 오류 발생 시 자체적으로 판단하고 디버깅하여 해결합니다.
|
||||
|
||||
### 4단계: 사용자 최종 테스트 지원 (Final Manual Testing)
|
||||
|
||||
- 내/외부 테스트를 거쳐 정상 구동이 확정된 버전을 사용자에게 보고하기 전에, 다음의 5단계를 수행합니다.
|
||||
- 사용자는 실질적으로 자신의 디스코드 서버에 봇을 초대하여 인게임/인앱 시나리오를 수동 테스트합니다.
|
||||
- **사후 검토**: 이 과정에서 사용자가 버그나 오류를 보고할 경우, 에이전트는 로컬 터미널의 로그(Log)나 DB의 상태를 검토(조회 명령어 사용 등)하여 무엇이 문제였는지 면밀히 파악하고 수정안을 제시해야 합니다.
|
||||
|
||||
### 5단계: 자동 문서화 및 인덱싱 (Documentation Phase)
|
||||
|
||||
- 3단계 구현 및 테스트가 성공적으로 완료되면, 사용자에게 최종 보고하기 **전에 반드시 먼저** `<PROJECT_ROOT>/Docs/` 디렉토리에 작업 완료(Work done), 트러블슈팅(Troubleshooting), 의사 결정(Decisions made) 내역을 문서화해야 합니다.
|
||||
- 새 문서가 생성되거나 수정되면 자동으로 `Docs/index.md`에 문서의 색인(링크)을 추가합니다.
|
||||
- 모든 코드 작업 내역과 의사 결정이 완전히 로컬 `Docs/`에 기록 및 정리된 후에만 비로소 "작업을 완료했다"고 사용자에게 알립니다.
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
---
|
||||
description: Security Rules (새 규칙)
|
||||
trigger: model_decision
|
||||
description: security
|
||||
---
|
||||
|
||||
이 워크플로우는 프로젝트 진행 시 잊지 않고 준수해야 하는 새로운 보안 규칙을 정의합니다.
|
||||
|
||||
## 핵심 보안 규칙 (Core Security Rules)
|
||||
|
|
@ -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)'**이라는 고유 명사를 프로젝트 전반(코드, 번역, 문서)에서 사용합니다.
|
||||
|
|
@ -40,14 +40,14 @@
|
|||
|
||||
---
|
||||
|
||||
### 2. ⬜ 권한 검사 (Permission Audit)
|
||||
### 2. ✅ 권한 검사 (Permission Audit)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목표** | 봇이 각 기능을 수행하기 위해 충분한 권한을 가지고 있는지 진단하고 보고서를 생성 |
|
||||
| **트리거** | 슬래시 명령어 (`/audit-permissions` 등) |
|
||||
| **출력** | 실행한 채널에 Embed 형태의 권한 진단 보고서 전송 |
|
||||
| **기획서** | `Docs/Plans/Permission_Audit_Plan.md` *(미작성)* |
|
||||
| **기획서** | [`Permission_Audit_Plan.md`](./Permission_Audit_Plan.md) |
|
||||
|
||||
**핵심 고려사항**
|
||||
- 기능별 필요 권한 매핑 테이블 (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` 등) |
|
||||
| **UI 형태** | Embed + Button + Select Menu 조합의 스텝 바이 스텝 인터랙션 |
|
||||
| **기획서** | `Docs/Plans/Setup_Wizard_Plan.md` *(미작성)* |
|
||||
| **기획서** | [`Setup_Wizard_Plan.md`](./Setup_Wizard_Plan.md) |
|
||||
|
||||
**핵심 고려사항**
|
||||
- 설정 항목 정의 (언어, 감사 채널, 임시 음성 채널 생성기 등)
|
||||
|
|
|
|||
|
|
@ -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) 오류 출력.
|
||||
|
|
@ -20,5 +20,12 @@ Kord 봇의 모든 유저 노출 기능은 글로벌 시장 대응을 위해 다
|
|||
### 3. 코드 연동
|
||||
- `t(locale, 'key', { vars })` 함수를 사용하여 번역된 문자열을 가져옵니다.
|
||||
|
||||
## 테스트 코드 준수 사항
|
||||
|
||||
테스트 코드에서도 번역된 문자열을 비교할 때는 하드코딩 대신 i18n 시스템을 참조해야 합니다.
|
||||
|
||||
- **검사 방법**: `npm run check-i18n` 명령어를 실행하여 하드코딩된 i18n 값이 있는지 확인합니다.
|
||||
- **예외 처리**: 의도적으로 하드코딩이 필요한 경우(예: i18n 자체 테스트), 해당 줄 끝에 `// i18n-ignore` 주석을 추가합니다.
|
||||
|
||||
## 변경 이력
|
||||
- **2026-03-27**: i18n 필수 적용 원칙 수립 및 가이드라인 생성
|
||||
|
|
|
|||
|
|
@ -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`) 완료.
|
||||
- 채널 권한 부족 시의 적절한 사용자 안내 메시지 출력 확인.
|
||||
|
|
@ -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) 등록 확인 완료.
|
||||
|
|
@ -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/*) 모두 통과 확인.
|
||||
|
|
@ -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 수준의 권한 점검 로직의 정확성 테스트 완료.
|
||||
|
|
@ -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**: 초기 에러 핸들링 구조 도입.
|
||||
|
||||
## 기여 및 결과
|
||||
- 디스코드 봇 기본 구동 확인.
|
||||
- 슬래시 명령어 자동 등록 및 인터랙션 핸들링 성공.
|
||||
- 데이터베이스 연동 및 기본 모델링 완료.
|
||||
|
|
@ -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`를 통해 닉네임 결정 로직이 의도한 우선순위대로 작동함을 확인했습니다.
|
||||
- **수동 테스트**: 서로 다른 서버에서 한 유저에게 다른 프로필이 각각 독립적으로 저장되고 적용됨을 확인했습니다.
|
||||
|
|
@ -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 파이프라인에 해당 검사 단계를 추가하여 코드 품질을 유지할 예정입니다.
|
||||
|
|
@ -2,14 +2,20 @@
|
|||
|
||||
이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다.
|
||||
|
||||
|
||||
## 정책 및 규칙 (Rules)
|
||||
|
||||
- [보안 가이드라인 (Security Rules)](Rules/security_guidelines.md)
|
||||
- [다국어 지원 개발 가이드라인 (i18n Development Guidelines)](Rules/i18n_guidelines.md)
|
||||
|
||||
|
||||
## 기능 명세 (Features)
|
||||
|
||||
- [임시 음성 채널 자동화 (Temp Voice Channels)](Features/temp_voice_channels.md)
|
||||
|
||||
|
||||
## 기획서 (Plans)
|
||||
|
||||
- [기능 로드맵 (Feature Roadmap)](Plans/Feature_Roadmap.md)
|
||||
- [임시 음성 채널 기능 기획서 (Temp Voice Channel Plan)](Plans/Temp_Voice_Channel_Plan.md)
|
||||
- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
||||
|
|
@ -17,15 +23,27 @@
|
|||
- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
||||
- [다국어 지원 기획서 (i18n Plan)](Plans/i18n_Plan.md)
|
||||
|
||||
|
||||
## 아키텍처 및 정책 결정 (Decisions)
|
||||
|
||||
- [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md)
|
||||
|
||||
|
||||
## 트러블슈팅 (Troubleshooting)
|
||||
|
||||
- [Voice Channel Missing Permissions (50013) 해결건](Troubleshooting/50013_Missing_Permissions.md)
|
||||
- [Temp Voice 유령 채널 미삭제 버그 해결건](Troubleshooting/handleLeave_ghost_channel.md)
|
||||
|
||||
|
||||
## 진행/완료 내역 (Work Done)
|
||||
|
||||
- [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: 임시 음성 채널 고도화 (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 테스트 코드 검사 도구 구현 (i18n Check Tool Implementation)](WorkDone/2026-03-27_i18n_Check_Tool_Implementation.md)
|
||||
- [2026-03-27: /config 명령어 및 기능 관리 리팩토링 (Config & Feature Refactoring)](WorkDone/2026-03-27_Config_And_Feature_Refactoring.md)
|
||||
- [2026-03-27: 감사 채널 구현 (Audit Log Channel Implementation)](WorkDone/2026-03-27_Audit_Log_Channel_Implementation.md)
|
||||
- [2026-03-27: 권한 진단 기능 구현 (Permission Audit Implementation)](WorkDone/2026-03-27_Permission_Audit_Implementation.md)
|
||||
- [2026-03-27: 에러 안내 UX 개선 및 통합 (Error Guidance UX Implementation)](WorkDone/2026-03-27_Error_Guidance_UX_Implementation.md)
|
||||
- [2026-03-27: Kord 프로젝트 초기 설정 (Project Initial Setup)](WorkDone/2026-03-27_Project_Initial_Setup.md)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "Kord",
|
||||
"name": "kord",
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"dependencies": {
|
||||
"@prisma/client": "6.4.1",
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"check-i18n": "tsx scripts/check-i18n-tests.ts"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
|
|
@ -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");
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "GuildConfig" ADD COLUMN "bigEmojiEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ALTER COLUMN "mimicEnabled" SET DEFAULT false;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "AuditChannel" ALTER COLUMN "disabledCategories" SET DEFAULT ARRAY['SYSTEM']::TEXT[];
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "AuditChannel" ALTER COLUMN "disabledCategories" SET DEFAULT ARRAY['BOOT', 'SYSTEM']::TEXT[];
|
||||
|
|
@ -10,7 +10,8 @@ datasource db {
|
|||
model GuildConfig {
|
||||
guildId String @id
|
||||
prefix String @default("!")
|
||||
mimicEnabled Boolean @default(true)
|
||||
mimicEnabled Boolean @default(false)
|
||||
bigEmojiEnabled Boolean @default(false)
|
||||
locale String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
|
@ -65,11 +66,14 @@ model TempVoiceChannel {
|
|||
}
|
||||
|
||||
model UserVoiceProfile {
|
||||
userId String @id
|
||||
userId String
|
||||
guildId String
|
||||
customName String?
|
||||
userLimit Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@id([userId, guildId])
|
||||
}
|
||||
|
||||
model UserLocale {
|
||||
|
|
@ -94,7 +98,15 @@ enum DeleteCondition {
|
|||
model AuditChannel {
|
||||
guildId String @id
|
||||
channelId String
|
||||
disabledCategories String[] @default([])
|
||||
disabledCategories String[] @default(["BOOT", "SYSTEM"])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model VoiceGuildConfig {
|
||||
guildId String @id
|
||||
defaultNameTemplate String @default("{{username}}'s Room")
|
||||
defaultUserLimit Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
|
|
@ -16,8 +16,7 @@ export class KordClient extends Client {
|
|||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildVoiceStates,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
// GatewayIntentBits.MessageContent, // Privileged -> Disabled for testing
|
||||
// GatewayIntentBits.GuildMembers, // Privileged -> Disabled for testing
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildInvites,
|
||||
],
|
||||
partials: [Partials.Message, Partials.Channel, Partials.GuildMember],
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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)'}** 되었습니다.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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(() => {});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
import { config } from 'dotenv';
|
||||
import { hostname } from 'os';
|
||||
config();
|
||||
|
||||
const generateInstanceId = () => {
|
||||
return process.env.INSTANCE_ID || hostname() || `kord-${Math.random().toString(36).substring(2, 7)}`;
|
||||
};
|
||||
|
||||
export const env = {
|
||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||
DISCORD_TOKEN: process.env.DISCORD_TOKEN || '',
|
||||
|
|
@ -10,4 +15,5 @@ export const env = {
|
|||
REDIS_PORT: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
VOICE_WAITING_ROOM_ID: process.env.VOICE_WAITING_ROOM_ID || '',
|
||||
VOICE_CATEGORY_ID: process.env.VOICE_CATEGORY_ID || '',
|
||||
INSTANCE_ID: generateInstanceId(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ErrorDefs, createBotError } from '../errors/ErrorCodes';
|
|||
import { ErrorReporter, withErrorHandler } from '../errors/ErrorReporter';
|
||||
import { t } from '../i18n';
|
||||
import { getInteractionLocale } from '../i18n/localeHelper';
|
||||
import { handleSetupWizardInteraction } from '../interactions/handlers/setupWizardHandler';
|
||||
|
||||
export default {
|
||||
name: Events.InteractionCreate,
|
||||
|
|
@ -21,6 +22,12 @@ export default {
|
|||
await command.execute(interaction, 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()) {
|
||||
const customId = interaction.customId;
|
||||
|
||||
|
|
@ -139,9 +146,9 @@ export default {
|
|||
await voiceChannel.setName(newName);
|
||||
|
||||
await prisma.userVoiceProfile.upsert({
|
||||
where: { userId: ownerId },
|
||||
where: { userId_guildId: { userId: ownerId, guildId: interaction.guildId! } },
|
||||
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 });
|
||||
|
|
@ -155,9 +162,9 @@ export default {
|
|||
await voiceChannel.setUserLimit(limit);
|
||||
|
||||
await prisma.userVoiceProfile.upsert({
|
||||
where: { userId: ownerId },
|
||||
where: { userId_guildId: { userId: ownerId, guildId: interaction.guildId! } },
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,24 @@
|
|||
import { Events, Message } from 'discord.js';
|
||||
import { MimicService } from '../services/MimicService';
|
||||
import { BigEmojiService } from '../services/BigEmojiService';
|
||||
import { prisma } from '../database';
|
||||
|
||||
export default {
|
||||
name: Events.MessageCreate,
|
||||
once: false,
|
||||
async execute(message: Message) {
|
||||
if (!message.guildId || message.author.bot) return;
|
||||
|
||||
const config = await prisma.guildConfig.findUnique({
|
||||
where: { guildId: message.guildId }
|
||||
});
|
||||
|
||||
if (config?.bigEmojiEnabled) {
|
||||
await BigEmojiService.handleMessage(message);
|
||||
}
|
||||
|
||||
if (config?.mimicEnabled) {
|
||||
await MimicService.handleMessage(message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { InviteService } from '../services/InviteService';
|
|||
import { VoiceService } from '../services/VoiceService';
|
||||
import { PresenceService } from '../services/PresenceService';
|
||||
import { auditLogService } from '../services/AuditLogService';
|
||||
import { redis } from '../cache';
|
||||
import { env } from '../config/env';
|
||||
|
||||
export default {
|
||||
name: Events.ClientReady,
|
||||
|
|
@ -16,19 +18,27 @@ export default {
|
|||
PresenceService.startActivePresence(client);
|
||||
|
||||
try {
|
||||
const lockKey = 'commands:sync:lock';
|
||||
// EX 300 = 5 minutes lock. Only one instance needs to do this per boot cycle.
|
||||
const acquired = await redis.set(lockKey, '1', 'EX', 300, 'NX');
|
||||
|
||||
if (acquired) {
|
||||
const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON());
|
||||
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) {
|
||||
logger.error('Failed to register global commands', e);
|
||||
}
|
||||
|
||||
client.guilds.cache.forEach(guild => {
|
||||
auditLogService.log(guild, {
|
||||
category: 'SYSTEM',
|
||||
category: 'BOOT',
|
||||
severity: 'INFO',
|
||||
title: 'Bot Online',
|
||||
description: `Kord has successfully started or reconnected.`
|
||||
description: `Kord instance **[${env.INSTANCE_ID}]** has successfully started or reconnected.`
|
||||
}).catch(() => {});
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,12 +11,18 @@ export const loadCommands = async (client: KordClient) => {
|
|||
|
||||
for (const file of commandFiles) {
|
||||
const filePath = path.join(commandsPath, file);
|
||||
const command = require(filePath).default;
|
||||
try {
|
||||
const module = require(filePath);
|
||||
const command = module.default || module;
|
||||
|
||||
if (command && 'data' in command && 'execute' in command) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -123,6 +123,18 @@ export const en: TranslationSchema = {
|
|||
setSuccess: 'Successfully 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: {
|
||||
description: 'Set the language for the bot.',
|
||||
scopeDescription: 'Apply to yourself or the entire server',
|
||||
|
|
@ -151,6 +163,78 @@ export const en: TranslationSchema = {
|
|||
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 ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -123,6 +123,18 @@ export const ko: TranslationSchema = {
|
|||
setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!',
|
||||
createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성 및 설정했습니다!',
|
||||
},
|
||||
voiceConfig: {
|
||||
description: '서버의 임시 음성 채널 설정을 관리합니다.',
|
||||
setNameTitle: '기본 이름 템플릿 설정',
|
||||
setNameDesc: '임시 채널 생성 시 사용할 기본 이름 형식을 설정합니다. (사용자명: {{username}})',
|
||||
setLimitTitle: '기본 인원 제한 설정',
|
||||
setLimitDesc: '임시 채널 생성 시 적용할 기본 인원 제한을 설정합니다.',
|
||||
statusTitle: '현재 서버 음성 설정',
|
||||
templateLabel: '이름 템플릿',
|
||||
limitLabel: '기본 인원 제한',
|
||||
setSuccess: '서버의 임시 채널 설정이 업데이트되었습니다.',
|
||||
limitValue: '{{limit}}명 (0 = 무제한)',
|
||||
},
|
||||
language: {
|
||||
description: '봇의 언어를 설정합니다.',
|
||||
scopeDescription: '본인에게만 또는 서버 전체에 적용',
|
||||
|
|
@ -151,6 +163,78 @@ export const ko: TranslationSchema = {
|
|||
MIMIC_WEBHOOK: '메시지 흉내 (Webhook)',
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
description: '설정 마법사를 실행하여 봇의 필수 기능들을 단계별로 설정합니다.',
|
||||
step0: {
|
||||
title: '✨ 봇 설정 마법사 시작',
|
||||
desc: '환영합니다! 이 마법사를 통해 아래 4가지 항목을 설정합니다.\n\n1️⃣ **언어 설정**\n2️⃣ **필수 권한 점검**\n3️⃣ **감사 채널 설정**\n4️⃣ **임시 음성 채널 설정**',
|
||||
startBtn: '설정 시작하기'
|
||||
},
|
||||
step1: {
|
||||
title: '1️⃣ 언어 설정',
|
||||
desc: '서버 전체에 적용될 봇의 기본 언어를 선택하세요. (현재: **{{locale}}**)',
|
||||
placeholder: '언어를 선택하세요',
|
||||
nextBtn: '다음 단계',
|
||||
skipBtn: '건너뛰기'
|
||||
},
|
||||
step2: {
|
||||
title: '2️⃣ 필수 권한 점검',
|
||||
descOk: '✅ **모든 필수 권한이 정상적으로 부여되어 있습니다.**',
|
||||
descFail: '⚠️ **일부 권한이 부족합니다.**\n결과를 확인하고 봇 역할에 필요한 기능 권한을 부여해주세요.',
|
||||
recheckBtn: '다시 검사하기',
|
||||
nextBtn: '다음 단계'
|
||||
},
|
||||
step3: {
|
||||
title: '3️⃣ 감사 채널 설정',
|
||||
desc: '봇의 주요 이벤트와 에러 통보를 받을 채널을 선택해주세요.',
|
||||
placeholder: '감사 통보 채널 선택',
|
||||
disableBtn: '감사 채널 끄기/해제',
|
||||
nextBtn: '다음 단계'
|
||||
},
|
||||
step4: {
|
||||
title: '감사 로그 카테고리 설정',
|
||||
desc: '로그를 수신할 카테고리를 선택해주세요.',
|
||||
nextBtn: '다음 단계',
|
||||
},
|
||||
step5: {
|
||||
title: '4️⃣ 임시 음성 채널 설정',
|
||||
desc: '임시 음성 채널을 생성할 "생성기 채널"을 선택해주세요.\n기존의 채널을 고르거나 카테고리/채널을 봇이 **자동 생성**하게 할 수도 있습니다.',
|
||||
placeholder: '생성기로 쓸 음성 채널 선택',
|
||||
autoBtn: '🚀 자동 생성하기',
|
||||
skipBtn: '임시 음성 사용 안함',
|
||||
nextBtn: '설정 완료'
|
||||
},
|
||||
step6: {
|
||||
title: '🎉 설정 완료 요약',
|
||||
desc: '**1. 언어**: {{lang}}\n**2. 감사 채널**: {{audit}}\n**3. 감사 카테고리**: {{categories}}\n**4. 임시 음성 채널**: {{voice}}',
|
||||
finishBtn: '마치기'
|
||||
},
|
||||
finished: '✅ 설정 마법사를 종료했습니다.',
|
||||
expired: '⏳ 시간이 만료되었습니다. `/setup`을 다시 실행해주세요.',
|
||||
defaultCategoryName: '음성 채널',
|
||||
defaultGeneratorName: '➕ 채널 생성하기',
|
||||
auditCategories: {
|
||||
SYSTEM: '시스템',
|
||||
BOOT: '부팅',
|
||||
VOICE: '음성',
|
||||
PERMISSION: '권한',
|
||||
INVITE: '초대',
|
||||
},
|
||||
},
|
||||
config: {
|
||||
title: '기능 설정 변경 결과',
|
||||
noOptions: '변경할 옵션을 하나 이상 선택해주세요.',
|
||||
mimic: {
|
||||
label: '미믹(Mimic)',
|
||||
enabled: '활성화',
|
||||
disabled: '비활성화',
|
||||
},
|
||||
emoji: {
|
||||
label: '이모지 확대(Big Emoji)',
|
||||
enabled: '활성화',
|
||||
disabled: '비활성화',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 모달 ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -77,6 +77,18 @@ export interface TranslationSchema {
|
|||
setSuccess: 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: {
|
||||
description: string;
|
||||
scopeDescription: string;
|
||||
|
|
@ -105,6 +117,41 @@ export interface TranslationSchema {
|
|||
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 ──
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { Guild, EmbedBuilder, TextChannel, Colors } from 'discord.js';
|
||||
import { prisma } from '../database';
|
||||
import { env } from '../config/env';
|
||||
|
||||
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 {
|
||||
category: AuditCategory;
|
||||
|
|
@ -47,7 +48,7 @@ export class AuditLogService {
|
|||
.setDescription(`**${payload.title}**\n\n${payload.description}`)
|
||||
.setColor(color)
|
||||
.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) {
|
||||
embed.addFields(payload.fields);
|
||||
|
|
@ -70,7 +71,12 @@ export class AuditLogService {
|
|||
async setChannel(guildId: string, channelId: string): Promise<void> {
|
||||
await prisma.auditChannel.upsert({
|
||||
where: { guildId },
|
||||
create: { guildId, channelId },
|
||||
create: {
|
||||
guildId,
|
||||
channelId,
|
||||
// 기본적으로 부팅 로그(BOOT)와 시스템 로그(SYSTEM)는 받지 않도록 설정
|
||||
disabledCategories: ['BOOT', 'SYSTEM']
|
||||
},
|
||||
update: { channelId },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,48 +10,27 @@ export class MimicService {
|
|||
let content = message.content;
|
||||
let modified = false;
|
||||
|
||||
// Feature 1: Big Emoji
|
||||
// If message is exactly one custom discord emoji, we enlarge it.
|
||||
const customEmojiRegex = /^<a?:.+:(\d+)>$/i;
|
||||
const match = content.match(customEmojiRegex);
|
||||
|
||||
if (match) {
|
||||
const emojiId = match[1];
|
||||
const isAnimated = content.startsWith('<a:');
|
||||
const ext = isAnimated ? 'gif' : 'png';
|
||||
const emojiUrl = `https://cdn.discordapp.com/emojis/${emojiId}.${ext}?size=256`;
|
||||
|
||||
// Replace the emoji string with its raw image URL
|
||||
content = emojiUrl;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// Feature 2: Prank / Word Mimic
|
||||
// Example logic replacing a keyword to alter user message
|
||||
if (content.includes('kord')) {
|
||||
// Feature: Word Mimic
|
||||
if (content.toLowerCase().includes('kord')) {
|
||||
content = content.replace(/kord/gi, 'Kord(최고존엄)');
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
try {
|
||||
// Ensure we have permissions to manage webhooks and messages
|
||||
const me = message.guild?.members.me;
|
||||
if (!me?.permissionsIn(message.channel).has(PermissionFlagsBits.ManageWebhooks)) {
|
||||
logger.warn(`Missing ManageWebhooks in ${message.channel.id}`);
|
||||
return; // Can't send mimic
|
||||
return;
|
||||
}
|
||||
|
||||
const webhookClient = await WebhookService.getWebhookClient(message.channel);
|
||||
if (webhookClient) {
|
||||
// Send modified message copying the user's name and avatar
|
||||
await webhookClient.send({
|
||||
content,
|
||||
username: message.member?.displayName || message.author.username,
|
||||
avatarURL: message.author.displayAvatarURL(),
|
||||
});
|
||||
|
||||
// Delete the original message silently
|
||||
if (message.deletable) {
|
||||
await message.delete();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { logger } from '../utils/logger';
|
||||
import { redis } from '../cache';
|
||||
|
|
@ -134,9 +134,29 @@ export class VoiceService {
|
|||
// Resolve locale for this context
|
||||
const locale = await getContextLocale(guild.id, member.id);
|
||||
|
||||
const profile = await prisma.userVoiceProfile.findUnique({ where: { userId: member.id }});
|
||||
const channelName = profile?.customName || t(locale, 'voice.defaultRoomName', { username: member.user.username });
|
||||
const userLimit = profile?.userLimit || 0;
|
||||
// Fetch guild-specific config
|
||||
const guildConfig = await prisma.voiceGuildConfig.findUnique({ where: { guildId: guild.id } });
|
||||
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 {
|
||||
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) {
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the member's name based on hierarchy:
|
||||
* 1. Server Nickname
|
||||
* 2. Global Display Name
|
||||
* 3. Username
|
||||
* 4. User ID (Fallback)
|
||||
*/
|
||||
public static getEffectiveName(member: GuildMember): string {
|
||||
return member.nickname || member.user.globalName || member.user.username || member.id;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ describe('i18n Core', () => {
|
|||
describe('t() - Translation Function', () => {
|
||||
it('should return English translation for a valid key', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -8,4 +8,27 @@ describe('VoiceService Test Suite', () => {
|
|||
await VoiceService.handleVoiceStateUpdate(mockState, mockState);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should resolve nickname correctly in getEffectiveName', () => {
|
||||
const mockMember = {
|
||||
id: '123',
|
||||
nickname: 'ServerNick',
|
||||
user: {
|
||||
globalName: 'GlobalName',
|
||||
username: 'UserBase',
|
||||
id: '123'
|
||||
}
|
||||
} as any;
|
||||
|
||||
expect(VoiceService.getEffectiveName(mockMember)).toBe('ServerNick');
|
||||
|
||||
mockMember.nickname = null;
|
||||
expect(VoiceService.getEffectiveName(mockMember)).toBe('GlobalName');
|
||||
|
||||
mockMember.user.globalName = null;
|
||||
expect(VoiceService.getEffectiveName(mockMember)).toBe('UserBase');
|
||||
|
||||
mockMember.user.username = null;
|
||||
expect(VoiceService.getEffectiveName(mockMember)).toBe('123');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
44
yarn.lock
44
yarn.lock
|
|
@ -1734,28 +1734,6 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 4.0.0
|
||||
resolution: "abbrev@npm:4.0.0"
|
||||
|
|
@ -3627,6 +3605,28 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 3.1.0
|
||||
resolution: "leven@npm:3.1.0"
|
||||
|
|
|
|||
Loading…
Reference in New Issue