feat: implement a structured error handling system with custom error types and a dedicated reporter.

This commit is contained in:
이정수 2026-03-27 14:03:12 +09:00
parent 2eedde7611
commit 6851fe2333
11 changed files with 1040 additions and 121 deletions

View File

@ -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에는 항상 친절한 메시지만, 서버 콘솔에만 상세 정보 기록.

View File

@ -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 키로 전환
- **다국어 지원** → 이후 모든 기능의 사용자 텍스트가 이 시스템을 사용
- **설정 도우미** → 다국어, 감사 채널 등 설정 가능한 기능이 먼저 존재해야 의미 있음

View File

@ -9,7 +9,9 @@
- [임시 음성 채널 자동화 (Temp Voice Channels)](Features/temp_voice_channels.md) - [임시 음성 채널 자동화 (Temp Voice Channels)](Features/temp_voice_channels.md)
## 기획서 (Plans) ## 기획서 (Plans)
- [기능 로드맵 (Feature Roadmap)](Plans/Feature_Roadmap.md)
- [임시 음성 채널 기능 기획서 (Temp Voice Channel Plan)](Plans/Temp_Voice_Channel_Plan.md) - [임시 음성 채널 기능 기획서 (Temp Voice Channel Plan)](Plans/Temp_Voice_Channel_Plan.md)
- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
## 아키텍처 및 정책 결정 (Decisions) ## 아키텍처 및 정책 결정 (Decisions)
- [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md) - [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md)

View File

@ -1,6 +1,6 @@
import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction, ChannelType } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction, ChannelType } from 'discord.js';
import { prisma } from '../database'; import { prisma } from '../database';
import { logger } from '../utils/logger'; import { ErrorCodes, createBotError } from '../errors/ErrorCodes';
export default { export default {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@ -45,14 +45,9 @@ export default {
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
const category = interaction.options.getChannel('category'); 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') { if (subcommand === 'set') {
const channel = interaction.options.getChannel('channel', true); const channel = interaction.options.getChannel('channel', true);
try {
await prisma.voiceGenerator.upsert({ await prisma.voiceGenerator.upsert({
where: { channelId: channel.id }, where: { channelId: channel.id },
update: { categoryId: category?.id || null, guildId: interaction.guildId! }, update: { categoryId: category?.id || null, guildId: interaction.guildId! },
@ -67,27 +62,16 @@ export default {
content: `Successfully set up ${channel} as a Voice Generator Channel!`, content: `Successfully set up ${channel} as a Voice Generator Channel!`,
ephemeral: true 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
});
}
} else if (subcommand === 'create') { } else if (subcommand === 'create') {
const name = interaction.options.getString('name', true); const name = interaction.options.getString('name', true);
try {
const guild = interaction.guild!; const guild = interaction.guild!;
// Create the new voice channel
const newChannel = await guild.channels.create({ const newChannel = await guild.channels.create({
name: name, name: name,
type: ChannelType.GuildVoice, type: ChannelType.GuildVoice,
parent: category?.id || null, parent: category?.id || null,
}); });
// Save to DB
await prisma.voiceGenerator.create({ await prisma.voiceGenerator.create({
data: { data: {
channelId: newChannel.id, channelId: newChannel.id,
@ -100,13 +84,6 @@ export default {
content: `Successfully created and set up ${newChannel} as a Voice Generator Channel!`, content: `Successfully created and set up ${newChannel} as a Voice Generator Channel!`,
ephemeral: true 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
});
}
} }
}, },
}; };

46
src/errors/BotError.ts Normal file
View File

@ -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;
}
}

144
src/errors/ErrorCodes.ts Normal file
View File

@ -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,
});
}

148
src/errors/ErrorReporter.ts Normal file
View File

@ -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);
}
}

View File

@ -2,6 +2,9 @@ import { Events, Interaction, ModalBuilder, TextInputBuilder, TextInputStyle, Ac
import { KordClient } from '../client/KordClient'; import { KordClient } from '../client/KordClient';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { prisma } from '../database'; import { prisma } from '../database';
import { BotError, ErrorCategory } from '../errors/BotError';
import { ErrorCodes, createBotError } from '../errors/ErrorCodes';
import { ErrorReporter, withErrorHandler } from '../errors/ErrorReporter';
export default { export default {
name: Events.InteractionCreate, name: Events.InteractionCreate,
@ -11,27 +14,21 @@ export default {
const command = client.commands.get(interaction.commandName); const command = client.commands.get(interaction.commandName);
if (!command) return; if (!command) return;
try { await withErrorHandler(interaction, async () => {
await command.execute(interaction); 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()) { else if (interaction.isStringSelectMenu()) {
const customId = interaction.customId; const customId = interaction.customId;
if (customId.startsWith('vc_control_')) { if (customId.startsWith('vc_control_')) {
await withErrorHandler(interaction, async () => {
const parts = customId.split('_'); const parts = customId.split('_');
const ownerId = parts[2]; const ownerId = parts[2];
const action = interaction.values[0]; const action = interaction.values[0];
if (interaction.user.id !== ownerId) { if (interaction.user.id !== ownerId) {
return interaction.reply({ content: 'Only the channel owner can use these controls.', ephemeral: true }); throw createBotError(ErrorCodes.NOT_CHANNEL_OWNER);
} }
const member = interaction.member as GuildMember; const member = interaction.member as GuildMember;
@ -40,11 +37,10 @@ export default {
if (!voiceChannel || interaction.channelId !== voiceChannel.id) { if (!voiceChannel || interaction.channelId !== voiceChannel.id) {
const tempDb = await prisma.tempVoiceChannel.findUnique({ where: { channelId: voiceChannel?.id || '' }}); const tempDb = await prisma.tempVoiceChannel.findUnique({ where: { channelId: voiceChannel?.id || '' }});
if (!tempDb || tempDb.ownerId !== ownerId) { 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') { if (action === 'rename') {
const modal = new ModalBuilder() const modal = new ModalBuilder()
.setCustomId(`modal_vc_rename_${ownerId}`) .setCustomId(`modal_vc_rename_${ownerId}`)
@ -112,26 +108,27 @@ export default {
.setMaxValues(1); .setMaxValues(1);
await interaction.reply({ content: 'Select who will become the new owner of this channel.', components: [new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(select)], ephemeral: true }); 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()) { else if (interaction.isModalSubmit()) {
const customId = interaction.customId; const customId = interaction.customId;
if (customId.startsWith('modal_vc_')) { if (customId.startsWith('modal_vc_')) {
await withErrorHandler(interaction, async () => {
const parts = customId.split('_'); const parts = customId.split('_');
const action = parts[2]; const action = parts[2];
const ownerId = parts[3]; 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 member = interaction.member as GuildMember;
const voiceChannel = member?.voice?.channel as VoiceChannel; const voiceChannel = member?.voice?.channel as VoiceChannel;
if (!voiceChannel) return interaction.reply({ content: 'Join your voice channel first.', ephemeral: true }); if (!voiceChannel) {
throw createBotError(ErrorCodes.MUST_BE_IN_VOICE_CHANNEL);
}
try {
if (action === 'rename') { if (action === 'rename') {
const newName = interaction.fields.getTextInputValue('newName'); const newName = interaction.fields.getTextInputValue('newName');
await voiceChannel.setName(newName); await voiceChannel.setName(newName);
@ -148,7 +145,7 @@ export default {
const limitStr = interaction.fields.getTextInputValue('limit'); const limitStr = interaction.fields.getTextInputValue('limit');
const limit = parseInt(limitStr); const limit = parseInt(limitStr);
if (isNaN(limit) || limit < 0 || limit > 99) { 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); await voiceChannel.setUserLimit(limit);
@ -160,36 +157,39 @@ export default {
await interaction.reply({ content: `Channel limit set to **${limit === 0 ? 'Unlimited' : limit}**!`, ephemeral: true }); 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()) { else if (interaction.isUserSelectMenu()) {
const customId = interaction.customId; const customId = interaction.customId;
if (customId.startsWith('select_vc_')) { if (customId.startsWith('select_vc_')) {
await withErrorHandler(interaction, async () => {
const parts = customId.split('_'); const parts = customId.split('_');
const action = parts[2]; const action = parts[2];
const ownerId = parts[3]; 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 member = interaction.member as GuildMember;
const voiceChannel = member?.voice?.channel as VoiceChannel; const voiceChannel = member?.voice?.channel as VoiceChannel;
if (!voiceChannel) return interaction.reply({ content: 'Join your voice channel first.', ephemeral: true }); if (!voiceChannel) {
throw createBotError(ErrorCodes.MUST_BE_IN_VOICE_CHANNEL);
}
const targetUserId = interaction.values[0]; const targetUserId = interaction.values[0];
if (targetUserId === ownerId) return interaction.reply({ content: 'You cannot target yourself.', ephemeral: true }); if (targetUserId === ownerId) {
throw createBotError(ErrorCodes.SELF_TARGET_NOT_ALLOWED);
}
try {
if (action === 'kick') { if (action === 'kick') {
const targetMember = await interaction.guild?.members.fetch(targetUserId); const targetMember = await interaction.guild?.members.fetch(targetUserId);
if (targetMember && targetMember.voice.channelId === voiceChannel.id) { if (targetMember && targetMember.voice.channelId === voiceChannel.id) {
await targetMember.voice.disconnect('Kicked by channel owner'); await targetMember.voice.disconnect('Kicked by channel owner');
await interaction.reply({ content: `Kicked <@${targetUserId}> from the channel.`, ephemeral: true }); await interaction.reply({ content: `Kicked <@${targetUserId}> from the channel.`, ephemeral: true });
} else { } else {
await interaction.reply({ content: `User is not in the channel.`, ephemeral: true }); throw createBotError(ErrorCodes.USER_NOT_IN_CHANNEL);
} }
} }
else if (action === 'ban') { else if (action === 'ban') {
@ -208,25 +208,19 @@ export default {
else if (action === 'transfer') { else if (action === 'transfer') {
const targetMember = await interaction.guild?.members.fetch(targetUserId); const targetMember = await interaction.guild?.members.fetch(targetUserId);
if (targetMember && targetMember.voice.channelId === voiceChannel.id) { if (targetMember && targetMember.voice.channelId === voiceChannel.id) {
// Update ownership in DB
const { prisma } = require('../database');
const { VoiceService } = require('../services/VoiceService');
await prisma.tempVoiceChannel.update({ await prisma.tempVoiceChannel.update({
where: { channelId: voiceChannel.id }, where: { channelId: voiceChannel.id },
data: { ownerId: targetUserId } data: { ownerId: targetUserId }
}); });
const { VoiceService } = require('../services/VoiceService');
await VoiceService.applyOwnershipTransfer(voiceChannel, ownerId, targetUserId); await VoiceService.applyOwnershipTransfer(voiceChannel, ownerId, targetUserId);
await interaction.reply({ content: `Ownership successfully transferred to <@${targetUserId}>.`, ephemeral: true }); await interaction.reply({ content: `Ownership successfully transferred to <@${targetUserId}>.`, ephemeral: true });
} else { } 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 });
}
} }
} }
}, },

View File

@ -2,6 +2,7 @@ import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBu
import { prisma } from '../database'; import { prisma } from '../database';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { redis } from '../cache'; import { redis } from '../cache';
import { ErrorCodes } from '../errors/ErrorCodes';
export class VoiceService { export class VoiceService {
public static async syncChannels(client: Client) { public static async syncChannels(client: Client) {
@ -87,7 +88,7 @@ export class VoiceService {
PermissionFlagsBits.ManageRoles, PermissionFlagsBits.ManageRoles,
PermissionFlagsBits.MoveMembers 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; return;
} }
@ -99,7 +100,7 @@ export class VoiceService {
try { try {
await member.voice.setChannel(existingTemp.channelId); await member.voice.setChannel(existingTemp.channelId);
} catch (e) { } 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; return;
} }
@ -135,7 +136,7 @@ export class VoiceService {
}); });
} catch (firstError: any) { } catch (firstError: any) {
if (firstError.code === 50013) { 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({ newChannel = await guild.channels.create({
name: channelName, name: channelName,
type: ChannelType.GuildVoice, type: ChannelType.GuildVoice,
@ -161,7 +162,7 @@ export class VoiceService {
await this.sendControlPanel(newChannel, member.id); await this.sendControlPanel(newChannel, member.id);
} catch (error) { } 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 } }); await prisma.tempVoiceChannel.delete({ where: { channelId } });
logger.info(`VoiceService: Successfully deleted temp channel ${channel.name}`); logger.info(`VoiceService: Successfully deleted temp channel ${channel.name}`);
} catch (error) { } 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. // 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); await this.applyOwnershipTransfer(channel, tempChannel.ownerId, newOwner.id);
logger.info(`VoiceService: Auto-transferred ownership to ${newOwner.user.tag}`); logger.info(`VoiceService: Auto-transferred ownership to ${newOwner.user.tag}`);
} catch (error) { } catch (error) {
logger.error(`VoiceService: Failed to auto-transfer ownership`, error); logger.error(`${ErrorCodes.UNKNOWN_ERROR.code}: VoiceService failed to auto-transfer ownership`, error);
} }
} }
} }

View File

@ -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');
});
});

View File

@ -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);
});
});