feat: implement a structured error handling system with custom error types and a dedicated reporter.
This commit is contained in:
parent
2eedde7611
commit
6851fe2333
|
|
@ -0,0 +1,242 @@
|
|||
# Kord - 에러 안내 기능 기획 (Error Guidance UX)
|
||||
|
||||
## 체인지로그 (Changelog)
|
||||
- **2026-03-27**: 최초 기획서 작성
|
||||
- **2026-03-27**: 결정 사항 확정 — 에러 코드 사용자 비노출, 서버 로그 전용 상세정보
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
사용자가 봇 인터랙션(슬래시 명령어, 셀렉트 메뉴, 모달 등)을 사용하다가 문제가 발생했을 때,
|
||||
**일관되고 친절한 에러 메시지**를 통해 상황을 안내하고 **재시도 또는 관리자 문의**를 유도하는 시스템입니다.
|
||||
|
||||
### 현재 문제 (As-Is)
|
||||
|
||||
| 문제 | 예시 |
|
||||
|------|------|
|
||||
| 에러 메시지가 하드코딩되어 있음 | `'There was an error while executing this command!'` |
|
||||
| 에러 유형 구분 없이 동일한 메시지 | DB 에러든, 권한 에러든, 사용자 입력 오류든 같은 문구 |
|
||||
| 사용자 행동 유도 없음 | 재시도 버튼, 관리자 알림 등 후속 액션 부재 |
|
||||
| 에러 추적 불가 | 에러 코드나 참조 ID 없음 |
|
||||
| 일부 에러가 조용히 무시됨 | `.catch(() => {})` 패턴으로 유실 |
|
||||
|
||||
### 목표 (To-Be)
|
||||
|
||||
- **에러 유형 분류** 체계화
|
||||
- **사용자 친화적 Embed** 기반 에러 메시지
|
||||
- **에러 코드** 부여로 추적성 확보
|
||||
- **액션 버튼** (재시도 안내, 관리자 문의 유도)
|
||||
- 향후 **다국어(i18n)** 시스템과 자연스럽게 통합할 수 있는 구조
|
||||
|
||||
---
|
||||
|
||||
## 2. 에러 유형 분류 (Error Categories)
|
||||
|
||||
에러를 발생 원인별로 4단계로 분류합니다.
|
||||
|
||||
| 카테고리 | 코드 접두사 | 설명 | 사용자 안내 방향 |
|
||||
|----------|-------------|------|------------------|
|
||||
| `USER_INPUT` | `E1xxx` | 잘못된 입력값 (범위 초과, 형식 오류 등) | 올바른 입력 예시 안내, 재입력 유도 |
|
||||
| `PERMISSION` | `E2xxx` | 봇 또는 사용자 권한 부족 | 필요 권한 안내, 서버 관리자 문의 유도 |
|
||||
| `BOT_INTERNAL` | `E3xxx` | 봇 내부 오류 (DB, Redis, 로직 오류) | 잠시 후 재시도 안내, 지속 시 관리자 문의 |
|
||||
| `DISCORD_API` | `E4xxx` | Discord API 오류 (Rate Limit, 서비스 장애 등) | 잠시 후 재시도, Discord 상태 확인 안내 |
|
||||
|
||||
### 주요 에러 코드 예시
|
||||
|
||||
```
|
||||
E1001 유효하지 않은 사용자 한도 값 (User Limit)
|
||||
E1002 채널 이름 형식 오류
|
||||
E2001 봇에 Manage Channels 권한 없음
|
||||
E2002 사용자에게 관리자 권한 없음
|
||||
E2003 채널 소유자만 사용 가능
|
||||
E3001 데이터베이스 연결/쿼리 실패
|
||||
E3002 캐시(Redis) 연결 실패
|
||||
E4001 Discord API Rate Limit
|
||||
E4002 Discord API 50013 (Missing Permissions)
|
||||
E4003 Discord API 일시적 오류
|
||||
```
|
||||
|
||||
> 에러 코드 체계는 확정이 아니며, 구현 과정에서 필요에 따라 추가·변경됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 에러 응답 포맷 (Error Response Design)
|
||||
|
||||
### 3-1. Embed 기반 에러 메시지
|
||||
|
||||
모든 에러 메시지는 **Ephemeral Embed** 형태로 전송합니다. 본인만 보이므로 채널을 어지럽히지 않습니다.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ❌ 작업을 완료할 수 없습니다 │ ← Title (에러 유형별 다름)
|
||||
│ │
|
||||
│ 채널 이름을 변경할 권한이 부족합니다. │ ← Description (사용자 친화적 설명)
|
||||
│ │
|
||||
│ 💡 해결 방법 │ ← Field: Resolution
|
||||
│ 서버 관리자에게 봇의 '채널 관리' │
|
||||
│ 권한을 확인해달라고 요청하세요. │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **에러 코드(`E2001` 등)는 사용자에게 노출하지 않습니다.**
|
||||
> 에러 코드와 스택 트레이스는 **서버 콘솔 로그에만** 기록되며, 봇 개발자/관리자가 직접 확인합니다.
|
||||
> 사용자에게는 친절한 안내 메시지와 해결 방법만 표시합니다.
|
||||
|
||||
### 3-2. 에러 유형별 색상 & 아이콘
|
||||
|
||||
| 카테고리 | Embed 색상 | 아이콘 | Title |
|
||||
|----------|-----------|--------|-------|
|
||||
| `USER_INPUT` | `#FFA500` (Orange) | ⚠️ | 입력을 확인해주세요 |
|
||||
| `PERMISSION` | `#FF6B6B` (Red) | 🔒 | 권한이 부족합니다 |
|
||||
| `BOT_INTERNAL` | `#FF4444` (Dark Red) | ❌ | 문제가 발생했습니다 |
|
||||
| `DISCORD_API` | `#7289DA` (Discord Blurple) | 🔄 | 일시적인 문제입니다 |
|
||||
|
||||
### 3-3. 에러 메시지 구성 요소
|
||||
|
||||
모든 에러 Embed는 아래 구성 요소를 포함합니다:
|
||||
|
||||
1. **Title** — 에러 유형에 따른 요약 제목
|
||||
2. **Description** — 사용자가 이해할 수 있는 구체적 설명
|
||||
3. **Field: Resolution** — 💡 해결 방법 또는 다음 행동 안내
|
||||
4. **(선택) Timestamp** — 에러 발생 시각
|
||||
|
||||
> 에러 코드는 서버 로그에만 기록됩니다. (e.g. `[ERROR] E2001: Missing Manage Channels permission in guild 123456`)
|
||||
|
||||
---
|
||||
|
||||
## 4. 기술 설계 (Technical Design)
|
||||
|
||||
### 4-1. 핵심 모듈: `BotError` 클래스 + `ErrorReporter` 유틸리티
|
||||
|
||||
```
|
||||
src/
|
||||
├── errors/
|
||||
│ ├── BotError.ts ← 커스텀 에러 클래스
|
||||
│ ├── ErrorCodes.ts ← 에러 코드 상수 정의
|
||||
│ └── ErrorReporter.ts ← Embed 생성 및 응답 전송 유틸리티
|
||||
```
|
||||
|
||||
#### `BotError` 클래스
|
||||
|
||||
```typescript
|
||||
// 개념 설계 (Conceptual)
|
||||
class BotError extends Error {
|
||||
code: string; // 'E2001'
|
||||
category: ErrorCategory; // 'PERMISSION'
|
||||
userMessage: string; // 사용자에게 보여줄 메시지
|
||||
resolution?: string; // 💡 해결 방법
|
||||
}
|
||||
```
|
||||
|
||||
#### `ErrorReporter` 유틸리티
|
||||
|
||||
```typescript
|
||||
// 개념 설계 (Conceptual)
|
||||
class ErrorReporter {
|
||||
// BotError를 받아 Embed를 생성하고 인터랙션에 응답
|
||||
static async report(interaction, error: BotError): Promise<void>;
|
||||
|
||||
// 알 수 없는 에러를 BotError로 래핑
|
||||
static wrap(error: unknown): BotError;
|
||||
}
|
||||
```
|
||||
|
||||
### 4-2. 사용 패턴 (Usage Pattern)
|
||||
|
||||
**Before (현재)**
|
||||
```typescript
|
||||
} catch (error) {
|
||||
logger.error('Button action error', error);
|
||||
if (!interaction.replied) await interaction.reply({ content: 'Failed to execute action.', ephemeral: true });
|
||||
}
|
||||
```
|
||||
|
||||
**After (개선)**
|
||||
```typescript
|
||||
} catch (error) {
|
||||
await ErrorReporter.report(interaction, ErrorReporter.wrap(error));
|
||||
}
|
||||
```
|
||||
|
||||
> `ErrorReporter.wrap()`는 알려진 Discord API 에러 코드(50013 등)를 자동 매핑하고,
|
||||
> 알 수 없는 에러는 `BOT_INTERNAL` 카테고리의 제네릭 에러로 래핑합니다.
|
||||
|
||||
### 4-3. 공통 에러 핸들러 래퍼
|
||||
|
||||
반복되는 try-catch 패턴을 줄이기 위해 **래퍼 함수**를 도입합니다.
|
||||
|
||||
```typescript
|
||||
// 개념 설계 (Conceptual)
|
||||
async function withErrorHandler(
|
||||
interaction: Interaction,
|
||||
fn: () => Promise<void>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
await ErrorReporter.report(interaction, ErrorReporter.wrap(error));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4-4. i18n 통합 준비
|
||||
|
||||
에러 메시지는 현재 영어 하드코딩으로 시작하되, 향후 i18n 전환을 위해:
|
||||
- 모든 사용자 표시 문자열을 `ErrorCodes.ts`에 **키-값 형태**로 집중 관리
|
||||
- i18n 구현 시 이 키를 locale 파일의 키로 1:1 매핑하면 전환 완료
|
||||
|
||||
---
|
||||
|
||||
## 5. 적용 범위
|
||||
|
||||
### Phase 1 — 인프라 구축
|
||||
- `BotError` 클래스, `ErrorCodes`, `ErrorReporter` 모듈 생성
|
||||
- `withErrorHandler` 래퍼 함수 구현
|
||||
|
||||
### Phase 2 — 기존 코드 마이그레이션
|
||||
- `interactionCreate.ts` 전체 에러 처리 교체
|
||||
- `voiceSetup.ts` 커맨드 에러 처리 교체
|
||||
- `VoiceService.ts` 에러 처리 개선 (조용한 실패 → 로깅 + 에러 보고)
|
||||
|
||||
### Phase 3 — 테스트 작성
|
||||
- `BotError` 클래스 유닛 테스트
|
||||
- `ErrorReporter.wrap()` 에러 매핑 유닛 테스트
|
||||
- `ErrorReporter.report()` Embed 생성 유닛 테스트
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증 계획 (Verification Plan)
|
||||
|
||||
### 자동 테스트 (Automated Tests)
|
||||
|
||||
1. **BotError 유닛 테스트** — `tests/errors/BotError.test.ts`
|
||||
- BotError 생성, 프로퍼티 검증
|
||||
- 실행 명령어: `yarn test -- --testPathPattern='tests/errors'`
|
||||
|
||||
2. **ErrorReporter 유닛 테스트** — `tests/errors/ErrorReporter.test.ts`
|
||||
- `wrap()`: Discord API 에러(50013 등) 자동 매핑 검증
|
||||
- `wrap()`: 알 수 없는 에러 → BOT_INTERNAL 래핑 검증
|
||||
- `report()`: interaction.reply/followUp 호출 검증 (mock)
|
||||
- 실행 명령어: `yarn test -- --testPathPattern='tests/errors'`
|
||||
|
||||
### 수동 테스트 (Manual Tests)
|
||||
|
||||
> Discord 봇 인터랙션은 자동화 테스트의 한계가 있으므로 사용자 수동 확인이 필요합니다.
|
||||
|
||||
1. **봇을 개발 서버에서 실행** (`yarn run dev`)
|
||||
2. **정상 동작 확인**: `/voice-setup create` 명령어가 정상적으로 작동하는지 확인
|
||||
3. **에러 유발 테스트**:
|
||||
- 봇에서 Manage Channels 권한을 제거한 뒤 `/voice-setup create` 실행 → 권한 에러 Embed 확인
|
||||
- 임시 음성 채널에서 User Limit에 `abc` 입력 → 입력 오류 Embed 확인
|
||||
4. **Embed 디자인 확인**: 에러 메시지에 올바른 색상, 아이콘, 에러 코드가 표시되는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 7. 결정 사항 (Decisions — 확정됨)
|
||||
|
||||
1. ✅ **에러 코드 비노출** — 사용자에게는 친절한 메시지만 표시. 에러 코드는 서버 로그에만 기록.
|
||||
2. ✅ **감사 채널 후순위** — 감사 채널 기능 구현 이후 연동. 현재는 서버 콘솔 로그만.
|
||||
3. ✅ **스택 트레이스 콘솔 전용** — 사용자 Embed에는 항상 친절한 메시지만, 서버 콘솔에만 상세 정보 기록.
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
# Kord - 기능 로드맵 (Feature Roadmap)
|
||||
|
||||
## 체인지로그 (Changelog)
|
||||
- **2026-03-27**: 로드맵 최초 작성 — 6개 기능 등록
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
임시 음성 채널 기능 완성 이후, Kord 봇을 프로덕션 수준의 서비스로 발전시키기 위한 **향후 기능 로드맵**입니다.
|
||||
각 항목은 구현에 앞서 별도 기획서(`Docs/Plans/`)를 작성·확정한 뒤 개발에 착수합니다.
|
||||
|
||||
### 상태 범례
|
||||
|
||||
| 아이콘 | 의미 |
|
||||
|--------|------|
|
||||
| ⬜ | 미착수 (Not Started) |
|
||||
| 📝 | 기획 중 (Planning) |
|
||||
| 🔨 | 개발 중 (In Progress) |
|
||||
| ✅ | 완료 (Done) |
|
||||
|
||||
---
|
||||
|
||||
## 로드맵 항목
|
||||
|
||||
### 1. ⬜ 다국어 지원 (i18n / Internationalization)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목표** | 봇의 모든 사용자 표시 텍스트(명령어 응답, 에러 메시지, UI 라벨 등)를 다국어로 제공 |
|
||||
| **기본 언어** | 영어 (English) — fallback 언어 |
|
||||
| **계층별 언어 설정** | 서버(Guild) 단위 → 사용자(User) 단위로 우선순위 적용 |
|
||||
| **기획서** | `Docs/Plans/i18n_Plan.md` *(미작성)* |
|
||||
|
||||
**핵심 고려사항**
|
||||
- Locale 키-값 구조 설계 (JSON / YAML 등)
|
||||
- discord.js `interaction.locale` / `interaction.guildLocale` 활용 여부
|
||||
- 번역 파일 관리 전략 (디렉터리 구조, 네임스페이스)
|
||||
- DB에 서버·사용자별 언어 설정 저장 (Prisma 스키마 확장)
|
||||
|
||||
---
|
||||
|
||||
### 2. ⬜ 권한 검사 (Permission Audit)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목표** | 봇이 각 기능을 수행하기 위해 충분한 권한을 가지고 있는지 진단하고 보고서를 생성 |
|
||||
| **트리거** | 슬래시 명령어 (`/audit-permissions` 등) |
|
||||
| **출력** | 실행한 채널에 Embed 형태의 권한 진단 보고서 전송 |
|
||||
| **기획서** | `Docs/Plans/Permission_Audit_Plan.md` *(미작성)* |
|
||||
|
||||
**핵심 고려사항**
|
||||
- 기능별 필요 권한 매핑 테이블 (Feature → Required Permissions)
|
||||
- 서버 수준 / 카테고리 수준 / 채널 수준 권한 검사
|
||||
- ✅ 충족 / ⚠️ 부족 항목을 시각적으로 구분하는 Embed 디자인
|
||||
- 관리자 전용 명령어로 제한
|
||||
|
||||
---
|
||||
|
||||
### 3. ⬜ 감사 채널 (Audit Log Channel)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목표** | 관리자가 지정한 텍스트 채널에 봇의 이벤트·문제 상황·서비스 상태 변동을 자동 기록 |
|
||||
| **등록 방식** | 관리자가 명령어로 채널 생성 또는 기존 채널 등록 |
|
||||
| **기록 대상** | 에러 발생, 봇 재시작, 기능 status 변동, 권한 이슈 감지 등 |
|
||||
| **기획서** | `Docs/Plans/Audit_Channel_Plan.md` *(미작성)* |
|
||||
|
||||
**핵심 고려사항**
|
||||
- 감사 로그 메시지 분류 체계 (severity: info / warn / error)
|
||||
- 서버당 감사 채널 수 제한 (티어 연동 가능성)
|
||||
- DB 스키마: `AuditChannel` 모델 설계
|
||||
- Rate Limit 대응 (대량 이벤트 발생 시 배치 전송 or 쓰로틀링)
|
||||
|
||||
---
|
||||
|
||||
### 4. ✅ 에러 안내 (Error Guidance UX)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목표** | 사용자 인터랙션 중 오류 발생 시, 친절한 메시지/모달로 안내하여 재시도 또는 관리자 문의를 유도 |
|
||||
| **표시 형태** | Ephemeral 메시지 또는 Modal |
|
||||
| **기획서** | `Docs/Plans/Error_Guidance_Plan.md` *(미작성)* |
|
||||
|
||||
**핵심 고려사항**
|
||||
- 에러 유형 분류 (사용자 입력 오류 / 권한 부족 / 봇 내부 오류 / Discord API 오류)
|
||||
- 유형별 안내 메시지 템플릿 설계
|
||||
- "재시도" 버튼 / "관리자에게 알리기" 버튼 등 액션 컴포넌트
|
||||
- 다국어 지원(i18n)과의 통합 — i18n 완료 후 연동
|
||||
- 에러 코드 체계 (추적·디버깅 용도)
|
||||
|
||||
---
|
||||
|
||||
### 5. ⬜ 봇 상태 메시지 (Bot Presence / Activity)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목표** | 봇의 Discord Presence에 `n개 서버에서 활동 중` 등의 동적 상태 메시지 표시 |
|
||||
| **갱신 주기** | 주기적 (예: 5분마다) 또는 서버 join/leave 이벤트 트리거 |
|
||||
| **기획서** | `Docs/Plans/Bot_Presence_Plan.md` *(미작성)* |
|
||||
|
||||
**핵심 고려사항**
|
||||
- `client.user.setActivity()` / `client.user.setPresence()` 활용
|
||||
- 상태 메시지 로테이션 (여러 메시지를 순환 표시)
|
||||
- ActivityType 선택 (Playing, Watching, Listening, Competing)
|
||||
- 샤딩(Sharding) 환경에서의 전체 서버 수 집계 방법
|
||||
|
||||
---
|
||||
|
||||
### 6. ⬜ 봇 설정 도우미 (Setup Wizard)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목표** | 서버 관리자가 인터랙션 기반으로 봇의 주요 설정을 단계별로 완료할 수 있는 설정 마법사 제공 |
|
||||
| **트리거** | 슬래시 명령어 (`/setup` 등) |
|
||||
| **UI 형태** | Embed + Button + Select Menu 조합의 스텝 바이 스텝 인터랙션 |
|
||||
| **기획서** | `Docs/Plans/Setup_Wizard_Plan.md` *(미작성)* |
|
||||
|
||||
**핵심 고려사항**
|
||||
- 설정 항목 정의 (언어, 감사 채널, 임시 음성 채널 생성기 등)
|
||||
- 단계(Step) 흐름 설계 (이전/다음/건너뛰기)
|
||||
- 설정 완료 시 요약 Embed 표시
|
||||
- 이미 설정된 항목 감지 및 스킵/변경 옵션
|
||||
- 다국어 지원(i18n)과의 통합
|
||||
|
||||
---
|
||||
|
||||
## 권장 구현 순서
|
||||
|
||||
> [!NOTE]
|
||||
> 순서는 기능 간 의존성과 인프라 우선도를 기준으로 제안합니다. 확정 전 논의가 필요합니다.
|
||||
|
||||
```
|
||||
1. 에러 안내 (Error Guidance UX) ← 모든 기능의 UX 품질 기반
|
||||
2. 다국어 지원 (i18n) ← 이후 모든 텍스트에 적용
|
||||
3. 봇 상태 메시지 (Bot Presence) ← 독립적, 비교적 간단
|
||||
4. 권한 검사 (Permission Audit) ← 운영 안정성 확보
|
||||
5. 감사 채널 (Audit Log Channel) ← 운영 모니터링 인프라
|
||||
6. 봇 설정 도우미 (Setup Wizard) ← 위 기능들이 갖춰진 뒤 통합 설정
|
||||
```
|
||||
|
||||
**의존성 관계:**
|
||||
- **에러 안내** → 다국어 지원 적용 시 메시지 템플릿이 i18n 키로 전환
|
||||
- **다국어 지원** → 이후 모든 기능의 사용자 텍스트가 이 시스템을 사용
|
||||
- **설정 도우미** → 다국어, 감사 채널 등 설정 가능한 기능이 먼저 존재해야 의미 있음
|
||||
|
|
@ -9,7 +9,9 @@
|
|||
- [임시 음성 채널 자동화 (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)
|
||||
|
||||
## 아키텍처 및 정책 결정 (Decisions)
|
||||
- [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction, ChannelType } from 'discord.js';
|
||||
import { prisma } from '../database';
|
||||
import { logger } from '../utils/logger';
|
||||
import { ErrorCodes, createBotError } from '../errors/ErrorCodes';
|
||||
|
||||
export default {
|
||||
data: new SlashCommandBuilder()
|
||||
|
|
@ -45,68 +45,45 @@ export default {
|
|||
const subcommand = interaction.options.getSubcommand();
|
||||
const category = interaction.options.getChannel('category');
|
||||
|
||||
// In a real application, we would check the user's tier here.
|
||||
// E.g. const tier = await prisma.userSubscription.findUnique(...)
|
||||
// And const count = await prisma.voiceGenerator.count(...)
|
||||
|
||||
if (subcommand === 'set') {
|
||||
const channel = interaction.options.getChannel('channel', true);
|
||||
|
||||
try {
|
||||
await prisma.voiceGenerator.upsert({
|
||||
where: { channelId: channel.id },
|
||||
update: { categoryId: category?.id || null, guildId: interaction.guildId! },
|
||||
create: {
|
||||
channelId: channel.id,
|
||||
guildId: interaction.guildId!,
|
||||
categoryId: category?.id || null,
|
||||
}
|
||||
});
|
||||
await prisma.voiceGenerator.upsert({
|
||||
where: { channelId: channel.id },
|
||||
update: { categoryId: category?.id || null, guildId: interaction.guildId! },
|
||||
create: {
|
||||
channelId: channel.id,
|
||||
guildId: interaction.guildId!,
|
||||
categoryId: category?.id || null,
|
||||
}
|
||||
});
|
||||
|
||||
await interaction.reply({
|
||||
content: `Successfully set up ${channel} as a Voice Generator Channel!`,
|
||||
ephemeral: true
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in voice-setup set command', error);
|
||||
await interaction.reply({
|
||||
content: 'Failed to save the configuration to the database.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
await interaction.reply({
|
||||
content: `Successfully set up ${channel} as a Voice Generator Channel!`,
|
||||
ephemeral: true
|
||||
});
|
||||
} else if (subcommand === 'create') {
|
||||
const name = interaction.options.getString('name', true);
|
||||
const guild = interaction.guild!;
|
||||
|
||||
try {
|
||||
const guild = interaction.guild!;
|
||||
const newChannel = await guild.channels.create({
|
||||
name: name,
|
||||
type: ChannelType.GuildVoice,
|
||||
parent: category?.id || null,
|
||||
});
|
||||
|
||||
// Create the new voice channel
|
||||
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,
|
||||
}
|
||||
});
|
||||
|
||||
// Save to DB
|
||||
await prisma.voiceGenerator.create({
|
||||
data: {
|
||||
channelId: newChannel.id,
|
||||
guildId: guild.id,
|
||||
categoryId: category?.id || null,
|
||||
}
|
||||
});
|
||||
|
||||
await interaction.reply({
|
||||
content: `Successfully created and set up ${newChannel} as a Voice Generator Channel!`,
|
||||
ephemeral: true
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in voice-setup create command', error);
|
||||
await interaction.reply({
|
||||
content: 'Failed to create the channel or save the configuration.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
await interaction.reply({
|
||||
content: `Successfully created and set up ${newChannel} as a Voice Generator Channel!`,
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Error category enum for classifying bot errors.
|
||||
*/
|
||||
export enum ErrorCategory {
|
||||
USER_INPUT = 'USER_INPUT',
|
||||
PERMISSION = 'PERMISSION',
|
||||
BOT_INTERNAL = 'BOT_INTERNAL',
|
||||
DISCORD_API = 'DISCORD_API',
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for Kord bot.
|
||||
* Contains structured error information for both user-facing messages and server logs.
|
||||
*/
|
||||
export class BotError extends Error {
|
||||
/** Error code for internal tracking (e.g. 'E2001') — NOT shown to users */
|
||||
public readonly code: string;
|
||||
|
||||
/** Error category for determining response styling */
|
||||
public readonly category: ErrorCategory;
|
||||
|
||||
/** User-facing message (friendly and helpful) */
|
||||
public readonly userMessage: string;
|
||||
|
||||
/** Suggested resolution for the user */
|
||||
public readonly resolution?: string;
|
||||
|
||||
/** Original error that caused this BotError */
|
||||
public readonly cause?: Error;
|
||||
|
||||
constructor(options: {
|
||||
code: string;
|
||||
category: ErrorCategory;
|
||||
userMessage: string;
|
||||
resolution?: string;
|
||||
cause?: Error;
|
||||
}) {
|
||||
super(`[${options.code}] ${options.userMessage}`);
|
||||
this.name = 'BotError';
|
||||
this.code = options.code;
|
||||
this.category = options.category;
|
||||
this.userMessage = options.userMessage;
|
||||
this.resolution = options.resolution;
|
||||
this.cause = options.cause;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import { BotError, ErrorCategory } from './BotError';
|
||||
|
||||
/**
|
||||
* Predefined error definitions for the Kord bot.
|
||||
* Error codes are prefixed by category:
|
||||
* E1xxx = USER_INPUT
|
||||
* E2xxx = PERMISSION
|
||||
* E3xxx = BOT_INTERNAL
|
||||
* E4xxx = DISCORD_API
|
||||
*
|
||||
* These codes are logged server-side only and NEVER exposed to end users.
|
||||
*/
|
||||
export const ErrorCodes = {
|
||||
// ── USER_INPUT (E1xxx) ──────────────────────────────────
|
||||
INVALID_USER_LIMIT: new BotError({
|
||||
code: 'E1001',
|
||||
category: ErrorCategory.USER_INPUT,
|
||||
userMessage: 'The user limit value is invalid.',
|
||||
resolution: 'Please enter a number between 0 and 99. (0 = unlimited)',
|
||||
}),
|
||||
|
||||
INVALID_CHANNEL_NAME: new BotError({
|
||||
code: 'E1002',
|
||||
category: ErrorCategory.USER_INPUT,
|
||||
userMessage: 'The channel name format is invalid.',
|
||||
resolution: 'Please enter a valid channel name (max 100 characters).',
|
||||
}),
|
||||
|
||||
SELF_TARGET_NOT_ALLOWED: new BotError({
|
||||
code: 'E1003',
|
||||
category: ErrorCategory.USER_INPUT,
|
||||
userMessage: 'You cannot perform this action on yourself.',
|
||||
}),
|
||||
|
||||
USER_NOT_IN_CHANNEL: new BotError({
|
||||
code: 'E1004',
|
||||
category: ErrorCategory.USER_INPUT,
|
||||
userMessage: 'The selected user is not in the voice channel.',
|
||||
resolution: 'Make sure the user is currently in the channel before performing this action.',
|
||||
}),
|
||||
|
||||
// ── PERMISSION (E2xxx) ──────────────────────────────────
|
||||
BOT_MISSING_MANAGE_CHANNELS: new BotError({
|
||||
code: 'E2001',
|
||||
category: ErrorCategory.PERMISSION,
|
||||
userMessage: 'The bot does not have sufficient permissions to manage channels.',
|
||||
resolution: 'Please ask a server administrator to grant the bot "Manage Channels" permission.',
|
||||
}),
|
||||
|
||||
BOT_MISSING_VOICE_PERMISSIONS: new BotError({
|
||||
code: 'E2002',
|
||||
category: ErrorCategory.PERMISSION,
|
||||
userMessage: 'The bot does not have sufficient voice channel permissions.',
|
||||
resolution: 'Please ask a server administrator to grant the bot "Manage Channels", "Manage Roles", and "Move Members" permissions.',
|
||||
}),
|
||||
|
||||
USER_NOT_ADMIN: new BotError({
|
||||
code: 'E2003',
|
||||
category: ErrorCategory.PERMISSION,
|
||||
userMessage: 'You do not have permission to use this command.',
|
||||
resolution: 'This command requires Administrator permission.',
|
||||
}),
|
||||
|
||||
NOT_CHANNEL_OWNER: new BotError({
|
||||
code: 'E2004',
|
||||
category: ErrorCategory.PERMISSION,
|
||||
userMessage: 'Only the channel owner can use these controls.',
|
||||
}),
|
||||
|
||||
MUST_BE_IN_VOICE_CHANNEL: new BotError({
|
||||
code: 'E2005',
|
||||
category: ErrorCategory.PERMISSION,
|
||||
userMessage: 'You must be in your active temporary voice channel to use this.',
|
||||
resolution: 'Join your temporary voice channel and try again.',
|
||||
}),
|
||||
|
||||
// ── BOT_INTERNAL (E3xxx) ────────────────────────────────
|
||||
DATABASE_ERROR: new BotError({
|
||||
code: 'E3001',
|
||||
category: ErrorCategory.BOT_INTERNAL,
|
||||
userMessage: 'An internal error occurred while processing your request.',
|
||||
resolution: 'Please try again in a moment. If the issue persists, contact the bot administrator.',
|
||||
}),
|
||||
|
||||
CACHE_ERROR: new BotError({
|
||||
code: 'E3002',
|
||||
category: ErrorCategory.BOT_INTERNAL,
|
||||
userMessage: 'An internal error occurred while processing your request.',
|
||||
resolution: 'Please try again in a moment.',
|
||||
}),
|
||||
|
||||
UNKNOWN_ERROR: new BotError({
|
||||
code: 'E3999',
|
||||
category: ErrorCategory.BOT_INTERNAL,
|
||||
userMessage: 'An unexpected error occurred.',
|
||||
resolution: 'Please try again later. If the problem continues, contact the bot administrator.',
|
||||
}),
|
||||
|
||||
// ── DISCORD_API (E4xxx) ─────────────────────────────────
|
||||
DISCORD_RATE_LIMITED: new BotError({
|
||||
code: 'E4001',
|
||||
category: ErrorCategory.DISCORD_API,
|
||||
userMessage: 'The action was rate-limited by Discord.',
|
||||
resolution: 'Please wait a moment and try again.',
|
||||
}),
|
||||
|
||||
DISCORD_MISSING_PERMISSIONS: new BotError({
|
||||
code: 'E4002',
|
||||
category: ErrorCategory.DISCORD_API,
|
||||
userMessage: 'Discord denied the action due to insufficient permissions.',
|
||||
resolution: 'Please ask a server administrator to check the bot\'s role and channel permissions.',
|
||||
}),
|
||||
|
||||
DISCORD_API_ERROR: new BotError({
|
||||
code: 'E4003',
|
||||
category: ErrorCategory.DISCORD_API,
|
||||
userMessage: 'A temporary issue occurred with Discord.',
|
||||
resolution: 'Please try again shortly. Check Discord\'s status at https://discordstatus.com if the issue persists.',
|
||||
}),
|
||||
|
||||
COMMAND_EXECUTION_FAILED: new BotError({
|
||||
code: 'E3003',
|
||||
category: ErrorCategory.BOT_INTERNAL,
|
||||
userMessage: 'An error occurred while executing this command.',
|
||||
resolution: 'Please try again. If the problem persists, contact the bot administrator.',
|
||||
}),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Creates a new BotError based on a predefined error code, with an optional cause.
|
||||
* This creates a fresh instance so that the `cause` field is unique per occurrence.
|
||||
*/
|
||||
export function createBotError(
|
||||
template: BotError,
|
||||
cause?: Error,
|
||||
): BotError {
|
||||
return new BotError({
|
||||
code: template.code,
|
||||
category: template.category,
|
||||
userMessage: template.userMessage,
|
||||
resolution: template.resolution,
|
||||
cause,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import {
|
||||
Interaction,
|
||||
EmbedBuilder,
|
||||
ChatInputCommandInteraction,
|
||||
MessageComponentInteraction,
|
||||
ModalSubmitInteraction,
|
||||
} from 'discord.js';
|
||||
import { BotError, ErrorCategory } from './BotError';
|
||||
import { ErrorCodes, createBotError } from './ErrorCodes';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/** Embed color per error category */
|
||||
const CATEGORY_COLORS: Record<ErrorCategory, number> = {
|
||||
[ErrorCategory.USER_INPUT]: 0xffa500, // Orange
|
||||
[ErrorCategory.PERMISSION]: 0xff6b6b, // Red
|
||||
[ErrorCategory.BOT_INTERNAL]: 0xff4444, // Dark Red
|
||||
[ErrorCategory.DISCORD_API]: 0x7289da, // Discord Blurple
|
||||
};
|
||||
|
||||
/** Emoji per error category */
|
||||
const CATEGORY_EMOJI: Record<ErrorCategory, string> = {
|
||||
[ErrorCategory.USER_INPUT]: '⚠️',
|
||||
[ErrorCategory.PERMISSION]: '🔒',
|
||||
[ErrorCategory.BOT_INTERNAL]: '❌',
|
||||
[ErrorCategory.DISCORD_API]: '🔄',
|
||||
};
|
||||
|
||||
/** Title per error category */
|
||||
const CATEGORY_TITLE: Record<ErrorCategory, string> = {
|
||||
[ErrorCategory.USER_INPUT]: 'Please check your input',
|
||||
[ErrorCategory.PERMISSION]: 'Insufficient permissions',
|
||||
[ErrorCategory.BOT_INTERNAL]: 'Something went wrong',
|
||||
[ErrorCategory.DISCORD_API]: 'Temporary issue',
|
||||
};
|
||||
|
||||
type RepliableInteraction =
|
||||
| ChatInputCommandInteraction
|
||||
| MessageComponentInteraction
|
||||
| ModalSubmitInteraction;
|
||||
|
||||
/**
|
||||
* Utility class for reporting errors as user-friendly Embeds.
|
||||
* Error codes and stack traces are logged server-side only.
|
||||
*/
|
||||
export class ErrorReporter {
|
||||
/**
|
||||
* Reports a BotError to the user via an ephemeral Embed,
|
||||
* and logs the full error details to the server console.
|
||||
*/
|
||||
static async report(
|
||||
interaction: RepliableInteraction,
|
||||
error: BotError,
|
||||
): Promise<void> {
|
||||
// ── Server-side logging (detailed) ──
|
||||
logger.error(
|
||||
`${error.code}: ${error.message}`,
|
||||
error.cause ? error.cause : '',
|
||||
);
|
||||
|
||||
// ── User-facing Embed (friendly, no error codes) ──
|
||||
const embed = this.buildEmbed(error);
|
||||
|
||||
try {
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({ embeds: [embed], ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
} catch (replyError) {
|
||||
// If even the error reply fails, log it silently
|
||||
logger.error('ErrorReporter: Failed to send error response to user', replyError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an unknown error into a structured BotError.
|
||||
* Automatically maps known Discord API error codes.
|
||||
*/
|
||||
static wrap(error: unknown): BotError {
|
||||
// Already a BotError — return as-is
|
||||
if (error instanceof BotError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// Map known Discord API error codes
|
||||
const discordCode = (error as any)?.code;
|
||||
if (typeof discordCode === 'number') {
|
||||
switch (discordCode) {
|
||||
case 50013:
|
||||
return createBotError(ErrorCodes.DISCORD_MISSING_PERMISSIONS, err);
|
||||
case 50001: // Missing Access
|
||||
return createBotError(ErrorCodes.BOT_MISSING_MANAGE_CHANNELS, err);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Map rate limit errors
|
||||
if ((error as any)?.httpStatus === 429) {
|
||||
return createBotError(ErrorCodes.DISCORD_RATE_LIMITED, err);
|
||||
}
|
||||
|
||||
// Fallback — unknown internal error
|
||||
return createBotError(ErrorCodes.UNKNOWN_ERROR, err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a user-friendly Embed from a BotError.
|
||||
* Does NOT include error codes — those stay in logs only.
|
||||
*/
|
||||
private static buildEmbed(error: BotError): EmbedBuilder {
|
||||
const emoji = CATEGORY_EMOJI[error.category];
|
||||
const title = CATEGORY_TITLE[error.category];
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(CATEGORY_COLORS[error.category])
|
||||
.setTitle(`${emoji} ${title}`)
|
||||
.setDescription(error.userMessage)
|
||||
.setTimestamp();
|
||||
|
||||
if (error.resolution) {
|
||||
embed.addFields({
|
||||
name: '💡 How to resolve',
|
||||
value: error.resolution,
|
||||
});
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an async handler function with standardized error handling.
|
||||
* Any thrown BotError is reported to the user; unknown errors are wrapped first.
|
||||
*/
|
||||
export async function withErrorHandler(
|
||||
interaction: RepliableInteraction,
|
||||
fn: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
const botError = ErrorReporter.wrap(error);
|
||||
await ErrorReporter.report(interaction, botError);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,9 @@ import { Events, Interaction, ModalBuilder, TextInputBuilder, TextInputStyle, Ac
|
|||
import { KordClient } from '../client/KordClient';
|
||||
import { logger } from '../utils/logger';
|
||||
import { prisma } from '../database';
|
||||
import { BotError, ErrorCategory } from '../errors/BotError';
|
||||
import { ErrorCodes, createBotError } from '../errors/ErrorCodes';
|
||||
import { ErrorReporter, withErrorHandler } from '../errors/ErrorReporter';
|
||||
|
||||
export default {
|
||||
name: Events.InteractionCreate,
|
||||
|
|
@ -11,40 +14,33 @@ export default {
|
|||
const command = client.commands.get(interaction.commandName);
|
||||
if (!command) return;
|
||||
|
||||
try {
|
||||
await withErrorHandler(interaction, async () => {
|
||||
await command.execute(interaction);
|
||||
} catch (error) {
|
||||
logger.error(`Error executing ${interaction.commandName}`, error);
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({ content: 'There was an error while executing this command!', ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (interaction.isStringSelectMenu()) {
|
||||
const customId = interaction.customId;
|
||||
|
||||
if (customId.startsWith('vc_control_')) {
|
||||
const parts = customId.split('_');
|
||||
const ownerId = parts[2];
|
||||
const action = interaction.values[0];
|
||||
await withErrorHandler(interaction, async () => {
|
||||
const parts = customId.split('_');
|
||||
const ownerId = parts[2];
|
||||
const action = interaction.values[0];
|
||||
|
||||
if (interaction.user.id !== ownerId) {
|
||||
return interaction.reply({ content: 'Only the channel owner can use these controls.', ephemeral: true });
|
||||
}
|
||||
if (interaction.user.id !== ownerId) {
|
||||
throw createBotError(ErrorCodes.NOT_CHANNEL_OWNER);
|
||||
}
|
||||
|
||||
const member = interaction.member as GuildMember;
|
||||
const voiceChannel = member?.voice?.channel as VoiceChannel;
|
||||
const member = interaction.member as GuildMember;
|
||||
const voiceChannel = member?.voice?.channel as VoiceChannel;
|
||||
|
||||
if (!voiceChannel || interaction.channelId !== voiceChannel.id) {
|
||||
if (!voiceChannel || interaction.channelId !== voiceChannel.id) {
|
||||
const tempDb = await prisma.tempVoiceChannel.findUnique({ where: { channelId: voiceChannel?.id || '' }});
|
||||
if (!tempDb || tempDb.ownerId !== ownerId) {
|
||||
return interaction.reply({ content: 'You must be in your active temporary voice channel to use this.', ephemeral: true });
|
||||
throw createBotError(ErrorCodes.MUST_BE_IN_VOICE_CHANNEL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'rename') {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(`modal_vc_rename_${ownerId}`)
|
||||
|
|
@ -112,26 +108,27 @@ export default {
|
|||
.setMaxValues(1);
|
||||
await interaction.reply({ content: 'Select who will become the new owner of this channel.', components: [new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(select)], ephemeral: true });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Button action error', error);
|
||||
if (!interaction.replied) await interaction.reply({ content: 'Failed to execute action.', ephemeral: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (interaction.isModalSubmit()) {
|
||||
const customId = interaction.customId;
|
||||
if (customId.startsWith('modal_vc_')) {
|
||||
const parts = customId.split('_');
|
||||
const action = parts[2];
|
||||
const ownerId = parts[3];
|
||||
await withErrorHandler(interaction, async () => {
|
||||
const parts = customId.split('_');
|
||||
const action = parts[2];
|
||||
const ownerId = parts[3];
|
||||
|
||||
if (interaction.user.id !== ownerId) return interaction.reply({ content: 'Unauthorized', ephemeral: true });
|
||||
if (interaction.user.id !== ownerId) {
|
||||
throw createBotError(ErrorCodes.NOT_CHANNEL_OWNER);
|
||||
}
|
||||
|
||||
const member = interaction.member as GuildMember;
|
||||
const voiceChannel = member?.voice?.channel as VoiceChannel;
|
||||
if (!voiceChannel) return interaction.reply({ content: 'Join your voice channel first.', ephemeral: true });
|
||||
const member = interaction.member as GuildMember;
|
||||
const voiceChannel = member?.voice?.channel as VoiceChannel;
|
||||
if (!voiceChannel) {
|
||||
throw createBotError(ErrorCodes.MUST_BE_IN_VOICE_CHANNEL);
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'rename') {
|
||||
const newName = interaction.fields.getTextInputValue('newName');
|
||||
await voiceChannel.setName(newName);
|
||||
|
|
@ -148,7 +145,7 @@ export default {
|
|||
const limitStr = interaction.fields.getTextInputValue('limit');
|
||||
const limit = parseInt(limitStr);
|
||||
if (isNaN(limit) || limit < 0 || limit > 99) {
|
||||
return interaction.reply({ content: 'Invalid limit number.', ephemeral: true });
|
||||
throw createBotError(ErrorCodes.INVALID_USER_LIMIT);
|
||||
}
|
||||
await voiceChannel.setUserLimit(limit);
|
||||
|
||||
|
|
@ -160,36 +157,39 @@ export default {
|
|||
|
||||
await interaction.reply({ content: `Channel limit set to **${limit === 0 ? 'Unlimited' : limit}**!`, ephemeral: true });
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Modal error', e);
|
||||
await interaction.reply({ content: 'Failed to process changes. Is rate-limited by Discord?', ephemeral: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (interaction.isUserSelectMenu()) {
|
||||
const customId = interaction.customId;
|
||||
if (customId.startsWith('select_vc_')) {
|
||||
const parts = customId.split('_');
|
||||
const action = parts[2];
|
||||
const ownerId = parts[3];
|
||||
await withErrorHandler(interaction, async () => {
|
||||
const parts = customId.split('_');
|
||||
const action = parts[2];
|
||||
const ownerId = parts[3];
|
||||
|
||||
if (interaction.user.id !== ownerId) return interaction.reply({ content: 'Unauthorized', ephemeral: true });
|
||||
if (interaction.user.id !== ownerId) {
|
||||
throw createBotError(ErrorCodes.NOT_CHANNEL_OWNER);
|
||||
}
|
||||
|
||||
const member = interaction.member as GuildMember;
|
||||
const voiceChannel = member?.voice?.channel as VoiceChannel;
|
||||
if (!voiceChannel) return interaction.reply({ content: 'Join your voice channel first.', ephemeral: true });
|
||||
const member = interaction.member as GuildMember;
|
||||
const voiceChannel = member?.voice?.channel as VoiceChannel;
|
||||
if (!voiceChannel) {
|
||||
throw createBotError(ErrorCodes.MUST_BE_IN_VOICE_CHANNEL);
|
||||
}
|
||||
|
||||
const targetUserId = interaction.values[0];
|
||||
if (targetUserId === ownerId) return interaction.reply({ content: 'You cannot target yourself.', ephemeral: true });
|
||||
const targetUserId = interaction.values[0];
|
||||
if (targetUserId === ownerId) {
|
||||
throw createBotError(ErrorCodes.SELF_TARGET_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'kick') {
|
||||
const targetMember = await interaction.guild?.members.fetch(targetUserId);
|
||||
if (targetMember && targetMember.voice.channelId === voiceChannel.id) {
|
||||
await targetMember.voice.disconnect('Kicked by channel owner');
|
||||
await interaction.reply({ content: `Kicked <@${targetUserId}> from the channel.`, ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ content: `User is not in the channel.`, ephemeral: true });
|
||||
throw createBotError(ErrorCodes.USER_NOT_IN_CHANNEL);
|
||||
}
|
||||
}
|
||||
else if (action === 'ban') {
|
||||
|
|
@ -208,25 +208,19 @@ export default {
|
|||
else if (action === 'transfer') {
|
||||
const targetMember = await interaction.guild?.members.fetch(targetUserId);
|
||||
if (targetMember && targetMember.voice.channelId === voiceChannel.id) {
|
||||
// Update ownership in DB
|
||||
const { prisma } = require('../database');
|
||||
const { VoiceService } = require('../services/VoiceService');
|
||||
|
||||
await prisma.tempVoiceChannel.update({
|
||||
where: { channelId: voiceChannel.id },
|
||||
data: { ownerId: targetUserId }
|
||||
});
|
||||
|
||||
const { VoiceService } = require('../services/VoiceService');
|
||||
await VoiceService.applyOwnershipTransfer(voiceChannel, ownerId, targetUserId);
|
||||
await interaction.reply({ content: `Ownership successfully transferred to <@${targetUserId}>.`, ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ content: `The selected user MUST be inside this voice channel right now to transfer ownership.`, ephemeral: true });
|
||||
throw createBotError(ErrorCodes.USER_NOT_IN_CHANNEL);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Select menu error', e);
|
||||
await interaction.reply({ content: 'Action failed.', ephemeral: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBu
|
|||
import { prisma } from '../database';
|
||||
import { logger } from '../utils/logger';
|
||||
import { redis } from '../cache';
|
||||
import { ErrorCodes } from '../errors/ErrorCodes';
|
||||
|
||||
export class VoiceService {
|
||||
public static async syncChannels(client: Client) {
|
||||
|
|
@ -87,7 +88,7 @@ export class VoiceService {
|
|||
PermissionFlagsBits.ManageRoles,
|
||||
PermissionFlagsBits.MoveMembers
|
||||
])) {
|
||||
logger.warn(`Bot lacks required Voice permissions (Manage Channels, Manage Roles, Move Members) in guild ${guild.id}`);
|
||||
logger.error(`${ErrorCodes.BOT_MISSING_VOICE_PERMISSIONS.code}: Bot lacks required Voice permissions in guild ${guild.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +100,7 @@ export class VoiceService {
|
|||
try {
|
||||
await member.voice.setChannel(existingTemp.channelId);
|
||||
} catch (e) {
|
||||
logger.error(`Could not move user to existing voice channel`, e);
|
||||
logger.error(`${ErrorCodes.DISCORD_API_ERROR.code}: Could not move user to existing voice channel`, e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -135,7 +136,7 @@ export class VoiceService {
|
|||
});
|
||||
} catch (firstError: any) {
|
||||
if (firstError.code === 50013) {
|
||||
logger.warn(`Missing Permissions when creating with overwrites. Bot perms: ${botMember?.permissions.bitfield.toString()}. Retrying without overwrites...`);
|
||||
logger.warn(`${ErrorCodes.DISCORD_MISSING_PERMISSIONS.code}: Missing Permissions when creating with overwrites. Bot perms: ${botMember?.permissions.bitfield.toString()}. Retrying without overwrites...`);
|
||||
newChannel = await guild.channels.create({
|
||||
name: channelName,
|
||||
type: ChannelType.GuildVoice,
|
||||
|
|
@ -161,7 +162,7 @@ export class VoiceService {
|
|||
|
||||
await this.sendControlPanel(newChannel, member.id);
|
||||
} catch (error) {
|
||||
logger.error(`VoiceService Join Error:`, error);
|
||||
logger.error(`${ErrorCodes.UNKNOWN_ERROR.code}: VoiceService Join Error`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,7 +198,7 @@ export class VoiceService {
|
|||
await prisma.tempVoiceChannel.delete({ where: { channelId } });
|
||||
logger.info(`VoiceService: Successfully deleted temp channel ${channel.name}`);
|
||||
} catch (error) {
|
||||
logger.error(`VoiceService: Discord API failed to delete channel ${channel.name} (Missing Manage Channels?)`, error);
|
||||
logger.error(`${ErrorCodes.DISCORD_MISSING_PERMISSIONS.code}: Failed to delete channel ${channel.name}`, error);
|
||||
// deliberately NOT deleting the database entry so it remains synchronized with Discord's state.
|
||||
}
|
||||
}
|
||||
|
|
@ -214,7 +215,7 @@ export class VoiceService {
|
|||
await this.applyOwnershipTransfer(channel, tempChannel.ownerId, newOwner.id);
|
||||
logger.info(`VoiceService: Auto-transferred ownership to ${newOwner.user.tag}`);
|
||||
} catch (error) {
|
||||
logger.error(`VoiceService: Failed to auto-transfer ownership`, error);
|
||||
logger.error(`${ErrorCodes.UNKNOWN_ERROR.code}: VoiceService failed to auto-transfer ownership`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { BotError, ErrorCategory } from '../../src/errors/BotError';
|
||||
|
||||
describe('BotError', () => {
|
||||
it('should create a BotError with all properties', () => {
|
||||
const error = new BotError({
|
||||
code: 'E1001',
|
||||
category: ErrorCategory.USER_INPUT,
|
||||
userMessage: 'Invalid input',
|
||||
resolution: 'Please try again',
|
||||
});
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(BotError);
|
||||
expect(error.name).toBe('BotError');
|
||||
expect(error.code).toBe('E1001');
|
||||
expect(error.category).toBe(ErrorCategory.USER_INPUT);
|
||||
expect(error.userMessage).toBe('Invalid input');
|
||||
expect(error.resolution).toBe('Please try again');
|
||||
expect(error.message).toBe('[E1001] Invalid input');
|
||||
});
|
||||
|
||||
it('should create a BotError without optional fields', () => {
|
||||
const error = new BotError({
|
||||
code: 'E3999',
|
||||
category: ErrorCategory.BOT_INTERNAL,
|
||||
userMessage: 'Something went wrong',
|
||||
});
|
||||
|
||||
expect(error.resolution).toBeUndefined();
|
||||
expect(error.cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve the original cause error', () => {
|
||||
const originalError = new Error('DB connection failed');
|
||||
const error = new BotError({
|
||||
code: 'E3001',
|
||||
category: ErrorCategory.BOT_INTERNAL,
|
||||
userMessage: 'Internal error',
|
||||
cause: originalError,
|
||||
});
|
||||
|
||||
expect(error.cause).toBe(originalError);
|
||||
expect(error.cause?.message).toBe('DB connection failed');
|
||||
});
|
||||
|
||||
it('should have correct category values', () => {
|
||||
expect(ErrorCategory.USER_INPUT).toBe('USER_INPUT');
|
||||
expect(ErrorCategory.PERMISSION).toBe('PERMISSION');
|
||||
expect(ErrorCategory.BOT_INTERNAL).toBe('BOT_INTERNAL');
|
||||
expect(ErrorCategory.DISCORD_API).toBe('DISCORD_API');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import { BotError, ErrorCategory } from '../../src/errors/BotError';
|
||||
import { ErrorCodes, createBotError } from '../../src/errors/ErrorCodes';
|
||||
import { ErrorReporter } from '../../src/errors/ErrorReporter';
|
||||
|
||||
// Mock logger to prevent console output during tests
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ErrorReporter', () => {
|
||||
describe('wrap()', () => {
|
||||
it('should return BotError as-is if already a BotError', () => {
|
||||
const original = createBotError(ErrorCodes.INVALID_USER_LIMIT);
|
||||
const wrapped = ErrorReporter.wrap(original);
|
||||
|
||||
expect(wrapped).toBe(original);
|
||||
expect(wrapped.code).toBe('E1001');
|
||||
});
|
||||
|
||||
it('should map Discord error code 50013 to DISCORD_MISSING_PERMISSIONS', () => {
|
||||
const discordError = new Error('Missing Permissions') as any;
|
||||
discordError.code = 50013;
|
||||
|
||||
const wrapped = ErrorReporter.wrap(discordError);
|
||||
|
||||
expect(wrapped).toBeInstanceOf(BotError);
|
||||
expect(wrapped.code).toBe('E4002');
|
||||
expect(wrapped.category).toBe(ErrorCategory.DISCORD_API);
|
||||
expect(wrapped.cause).toBe(discordError);
|
||||
});
|
||||
|
||||
it('should map Discord error code 50001 to BOT_MISSING_MANAGE_CHANNELS', () => {
|
||||
const discordError = new Error('Missing Access') as any;
|
||||
discordError.code = 50001;
|
||||
|
||||
const wrapped = ErrorReporter.wrap(discordError);
|
||||
|
||||
expect(wrapped).toBeInstanceOf(BotError);
|
||||
expect(wrapped.code).toBe('E2001');
|
||||
expect(wrapped.category).toBe(ErrorCategory.PERMISSION);
|
||||
});
|
||||
|
||||
it('should map HTTP 429 to DISCORD_RATE_LIMITED', () => {
|
||||
const rateLimitError = new Error('Rate Limited') as any;
|
||||
rateLimitError.httpStatus = 429;
|
||||
|
||||
const wrapped = ErrorReporter.wrap(rateLimitError);
|
||||
|
||||
expect(wrapped).toBeInstanceOf(BotError);
|
||||
expect(wrapped.code).toBe('E4001');
|
||||
expect(wrapped.category).toBe(ErrorCategory.DISCORD_API);
|
||||
});
|
||||
|
||||
it('should wrap unknown errors as UNKNOWN_ERROR', () => {
|
||||
const unknownError = new Error('Something unexpected');
|
||||
const wrapped = ErrorReporter.wrap(unknownError);
|
||||
|
||||
expect(wrapped).toBeInstanceOf(BotError);
|
||||
expect(wrapped.code).toBe('E3999');
|
||||
expect(wrapped.category).toBe(ErrorCategory.BOT_INTERNAL);
|
||||
expect(wrapped.cause).toBe(unknownError);
|
||||
});
|
||||
|
||||
it('should handle non-Error objects (e.g. strings)', () => {
|
||||
const wrapped = ErrorReporter.wrap('string error');
|
||||
|
||||
expect(wrapped).toBeInstanceOf(BotError);
|
||||
expect(wrapped.code).toBe('E3999');
|
||||
});
|
||||
});
|
||||
|
||||
describe('report()', () => {
|
||||
it('should reply with an ephemeral embed when interaction has not been replied', async () => {
|
||||
const mockInteraction = {
|
||||
replied: false,
|
||||
deferred: false,
|
||||
reply: jest.fn().mockResolvedValue(undefined),
|
||||
followUp: jest.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
|
||||
const error = createBotError(ErrorCodes.NOT_CHANNEL_OWNER);
|
||||
|
||||
await ErrorReporter.report(mockInteraction, error);
|
||||
|
||||
expect(mockInteraction.reply).toHaveBeenCalledTimes(1);
|
||||
expect(mockInteraction.followUp).not.toHaveBeenCalled();
|
||||
|
||||
const replyArgs = mockInteraction.reply.mock.calls[0][0];
|
||||
expect(replyArgs.ephemeral).toBe(true);
|
||||
expect(replyArgs.embeds).toHaveLength(1);
|
||||
|
||||
// Verify embed does NOT contain error code
|
||||
const embed = replyArgs.embeds[0];
|
||||
const embedJSON = embed.toJSON();
|
||||
expect(embedJSON.description).not.toContain('E2004');
|
||||
expect(embedJSON.footer).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should followUp when interaction has already been replied', async () => {
|
||||
const mockInteraction = {
|
||||
replied: true,
|
||||
deferred: false,
|
||||
reply: jest.fn().mockResolvedValue(undefined),
|
||||
followUp: jest.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
|
||||
const error = createBotError(ErrorCodes.DATABASE_ERROR);
|
||||
|
||||
await ErrorReporter.report(mockInteraction, error);
|
||||
|
||||
expect(mockInteraction.followUp).toHaveBeenCalledTimes(1);
|
||||
expect(mockInteraction.reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include resolution field in embed when available', async () => {
|
||||
const mockInteraction = {
|
||||
replied: false,
|
||||
deferred: false,
|
||||
reply: jest.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
|
||||
const error = createBotError(ErrorCodes.INVALID_USER_LIMIT);
|
||||
|
||||
await ErrorReporter.report(mockInteraction, error);
|
||||
|
||||
const embed = mockInteraction.reply.mock.calls[0][0].embeds[0];
|
||||
const embedJSON = embed.toJSON();
|
||||
expect(embedJSON.fields).toHaveLength(1);
|
||||
expect(embedJSON.fields[0].name).toBe('💡 How to resolve');
|
||||
});
|
||||
|
||||
it('should not throw even if reply fails', async () => {
|
||||
const mockInteraction = {
|
||||
replied: false,
|
||||
deferred: false,
|
||||
reply: jest.fn().mockRejectedValue(new Error('Network error')),
|
||||
} as any;
|
||||
|
||||
const error = createBotError(ErrorCodes.UNKNOWN_ERROR);
|
||||
|
||||
// Should not throw
|
||||
await expect(ErrorReporter.report(mockInteraction, error)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBotError()', () => {
|
||||
it('should create a fresh BotError instance from a template', () => {
|
||||
const error1 = createBotError(ErrorCodes.DATABASE_ERROR);
|
||||
const error2 = createBotError(ErrorCodes.DATABASE_ERROR);
|
||||
|
||||
expect(error1).not.toBe(error2);
|
||||
expect(error1.code).toBe(error2.code);
|
||||
expect(error1.userMessage).toBe(error2.userMessage);
|
||||
});
|
||||
|
||||
it('should attach a cause error', () => {
|
||||
const cause = new Error('Connection refused');
|
||||
const error = createBotError(ErrorCodes.DATABASE_ERROR, cause);
|
||||
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue