diff --git a/Docs/Plans/Error_Guidance_Plan.md b/Docs/Plans/Error_Guidance_Plan.md new file mode 100644 index 0000000..68c1cae --- /dev/null +++ b/Docs/Plans/Error_Guidance_Plan.md @@ -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; + + // 알 수 없는 에러를 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 +): Promise { + 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에는 항상 친절한 메시지만, 서버 콘솔에만 상세 정보 기록. diff --git a/Docs/Plans/Feature_Roadmap.md b/Docs/Plans/Feature_Roadmap.md new file mode 100644 index 0000000..e95669c --- /dev/null +++ b/Docs/Plans/Feature_Roadmap.md @@ -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 키로 전환 +- **다국어 지원** → 이후 모든 기능의 사용자 텍스트가 이 시스템을 사용 +- **설정 도우미** → 다국어, 감사 채널 등 설정 가능한 기능이 먼저 존재해야 의미 있음 diff --git a/Docs/index.md b/Docs/index.md index 759369f..fa9e74c 100644 --- a/Docs/index.md +++ b/Docs/index.md @@ -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) diff --git a/src/commands/voiceSetup.ts b/src/commands/voiceSetup.ts index f44b11d..45cfb41 100644 --- a/src/commands/voiceSetup.ts +++ b/src/commands/voiceSetup.ts @@ -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!; + + const newChannel = await guild.channels.create({ + name: name, + type: ChannelType.GuildVoice, + parent: category?.id || null, + }); - try { - const guild = interaction.guild!; - - // 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 + }); } }, }; diff --git a/src/errors/BotError.ts b/src/errors/BotError.ts new file mode 100644 index 0000000..79edef1 --- /dev/null +++ b/src/errors/BotError.ts @@ -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; + } +} diff --git a/src/errors/ErrorCodes.ts b/src/errors/ErrorCodes.ts new file mode 100644 index 0000000..8dcda7a --- /dev/null +++ b/src/errors/ErrorCodes.ts @@ -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, + }); +} diff --git a/src/errors/ErrorReporter.ts b/src/errors/ErrorReporter.ts new file mode 100644 index 0000000..a754846 --- /dev/null +++ b/src/errors/ErrorReporter.ts @@ -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.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.USER_INPUT]: '⚠️', + [ErrorCategory.PERMISSION]: '🔒', + [ErrorCategory.BOT_INTERNAL]: '❌', + [ErrorCategory.DISCORD_API]: '🔄', +}; + +/** Title per error category */ +const CATEGORY_TITLE: Record = { + [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 { + // ── 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, +): Promise { + try { + await fn(); + } catch (error) { + const botError = ErrorReporter.wrap(error); + await ErrorReporter.report(interaction, botError); + } +} diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 6772cdc..e96890d 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -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; - - if (!voiceChannel || interaction.channelId !== voiceChannel.id) { + const member = interaction.member as GuildMember; + const voiceChannel = member?.voice?.channel as VoiceChannel; + + 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().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 }); - - 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 }); + 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) { + 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 }); - } + }); } } }, diff --git a/src/services/VoiceService.ts b/src/services/VoiceService.ts index ca121b2..16b1bb5 100644 --- a/src/services/VoiceService.ts +++ b/src/services/VoiceService.ts @@ -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); } } } diff --git a/tests/errors/BotError.test.ts b/tests/errors/BotError.test.ts new file mode 100644 index 0000000..2a16a4c --- /dev/null +++ b/tests/errors/BotError.test.ts @@ -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'); + }); +}); diff --git a/tests/errors/ErrorReporter.test.ts b/tests/errors/ErrorReporter.test.ts new file mode 100644 index 0000000..6d8ac60 --- /dev/null +++ b/tests/errors/ErrorReporter.test.ts @@ -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); + }); +});