diff --git a/Docs/Plans/Feature_Roadmap.md b/Docs/Plans/Feature_Roadmap.md index e95669c..8844ef7 100644 --- a/Docs/Plans/Feature_Roadmap.md +++ b/Docs/Plans/Feature_Roadmap.md @@ -23,14 +23,14 @@ ## 로드맵 항목 -### 1. ⬜ 다국어 지원 (i18n / Internationalization) +### 1. ✅ 다국어 지원 (i18n / Internationalization) | 항목 | 내용 | |------|------| | **목표** | 봇의 모든 사용자 표시 텍스트(명령어 응답, 에러 메시지, UI 라벨 등)를 다국어로 제공 | | **기본 언어** | 영어 (English) — fallback 언어 | | **계층별 언어 설정** | 서버(Guild) 단위 → 사용자(User) 단위로 우선순위 적용 | -| **기획서** | `Docs/Plans/i18n_Plan.md` *(미작성)* | +| **기획서** | [`i18n_Plan.md`](./i18n_Plan.md) | **핵심 고려사항** - Locale 키-값 구조 설계 (JSON / YAML 등) diff --git a/Docs/Plans/i18n_Plan.md b/Docs/Plans/i18n_Plan.md new file mode 100644 index 0000000..d7eeaa6 --- /dev/null +++ b/Docs/Plans/i18n_Plan.md @@ -0,0 +1,491 @@ +# Kord - 다국어 지원 기획 (i18n / Internationalization) + +## 체인지로그 (Changelog) +- **2026-03-27**: 최초 기획서 작성 + +--- + +## 1. 개요 + +Kord 봇의 모든 **사용자 표시 텍스트**(명령어 응답, 에러 메시지, UI 라벨, 컨트롤 패널 등)를 다국어로 제공하는 시스템입니다. + +### 현재 문제 (As-Is) + +| 문제 | 위치 | +|------|------| +| 모든 사용자 메시지가 영어로 하드코딩 | `ErrorCodes.ts`, `interactionCreate.ts`, `VoiceService.ts`, `voiceSetup.ts` | +| Discord `interaction.locale` 미활용 | discord.js가 제공하는 사용자/서버 locale 정보를 무시 | +| 에러 메시지 키-값 구조 미비 | `ErrorCodes.ts`에 문자열 직접 포함 | +| 서버/사용자별 언어 설정 불가 | DB에 locale 필드 없음 | + +### 목표 (To-Be) + +- **Locale 키-값 구조** 기반 모든 사용자 문자열 관리 +- **서버(Guild) → 사용자(User)** 우선순위 기반 언어 결정 +- **discord.js `interaction.locale` / `guildLocale`** 활용한 자동 감지 +- **에러 시스템(ErrorCodes)** 과 자연스러운 통합 +- 영어(en) fallback + **한국어(ko)** 우선 지원 + +--- + +## 2. 언어 결정 우선순위 (Locale Resolution) + +사용자에게 표시할 언어는 다음 우선순위로 결정합니다: + +``` +1. 사용자 개인 설정 (DB: UserLocale.locale) ← 최우선 +2. 서버 설정 (DB: GuildConfig.locale) ← 2순위 +3. Discord interaction.locale ← 3순위 (자동 감지) +4. 기본 언어: 'en' (English) ← fallback +``` + +> [!NOTE] +> Discord의 `interaction.locale`은 사용자의 Discord 클라이언트 언어를 자동으로 감지하므로, +> DB에 별도 설정이 없어도 적절한 언어가 자동 선택됩니다. + +### 지원 언어 (초기) + +| 코드 | 언어 | +|------|------| +| `en` | English (기본, fallback) | +| `ko` | 한국어 | + +향후 `ja`, `zh-CN` 등 추가 가능. 번역 파일만 추가하면 자동 인식. + +--- + +## 3. 번역 파일 구조 (Translation Files) + +### 3-1. 디렉터리 구조 + +``` +src/ +├── i18n/ +│ ├── index.ts ← t() 함수, resolveLocale() 등 핵심 모듈 +│ ├── types.ts ← 타입 정의 (TranslationKeys, Locale 등) +│ └── locales/ +│ ├── en.ts ← 영어 번역 (기본, 전체 키 포함) +│ └── ko.ts ← 한국어 번역 +``` + +### 3-2. 번역 파일 형태 (TypeScript Object) + +JSON/YAML 대신 **TypeScript 객체**를 사용합니다. +이유: 타입 안전성, IDE 자동완성, 빌드 타임 검증 가능. + +```typescript +// src/i18n/locales/en.ts +export const en = { + // ── 에러 메시지 (Error Messages) ── + errors: { + E1001: { + userMessage: 'The user limit value is invalid.', + resolution: 'Please enter a number between 0 and 99. (0 = unlimited)', + }, + E2001: { + userMessage: 'The bot does not have sufficient permissions to manage channels.', + resolution: 'Please ask a server administrator to grant the bot "Manage Channels" permission.', + }, + // ... 모든 ErrorCodes의 userMessage/resolution + }, + + // ── 에러 카테고리 타이틀 ── + errorTitles: { + USER_INPUT: 'Please check your input', + PERMISSION: 'Insufficient permissions', + BOT_INTERNAL: 'Something went wrong', + DISCORD_API: 'Temporary issue', + }, + + // ── 에러 Embed 필드명 ── + errorFields: { + resolution: '💡 How to resolve', + }, + + // ── 음성 채널 (Voice Channel) ── + voice: { + channelReady: '{{owner}}, your temporary channel is ready! Use the dropdown menu below to manage it.', + defaultRoomName: "{{username}}'s Room", + controlPanel: { + placeholder: '⚙️ Manage Channel Settings', + rename: 'Rename Channel', + limit: 'Set User Limit', + lock: 'Lock / Unlock', + kick: 'Kick User', + ban: 'Ban / Hide User', + transfer: 'Transfer Ownership', + }, + // 인터랙션 응답 + responses: { + channelLocked: 'Channel Locked! Only you and invited members can join.', + channelUnlocked: 'Channel Unlocked! Anyone can join now.', + channelRenamed: 'Channel renamed to **{{name}}**!', + limitSet: 'Channel limit set to **{{limit}}**!', + limitUnlimited: 'Unlimited', + kicked: 'Kicked {{user}} from the channel.', + banned: 'Banned and hidden channel from {{user}}.', + transferPrompt: 'Select who will become the new owner of this channel.', + transferDone: 'Ownership successfully transferred to {{user}}.', + banPrompt: 'Banning will make the channel invisible to them.', + }, + }, + + // ── 명령어 (Commands) ── + commands: { + voiceSetup: { + setSuccess: 'Successfully set up {{channel}} as a Voice Generator Channel!', + createSuccess: 'Successfully created and set up {{channel}} as a Voice Generator Channel!', + }, + }, + + // ── 모달 (Modals) ── + modals: { + renameTitle: 'Rename Voice Channel', + renameLabel: 'New Channel Name', + limitTitle: 'Set User Limit', + limitLabel: 'User Limit (0 for unlimited, 1-99)', + }, + + // ── 셀렉트 메뉴 Placeholder ── + selects: { + kickUser: 'Select a user to kick', + banUser: 'Select a user to ban/hide', + transferOwner: 'Select a user to transfer ownership to', + }, +} as const; +``` + +```typescript +// src/i18n/locales/ko.ts +export const ko = { + errors: { + E1001: { + userMessage: '사용자 제한 값이 올바르지 않습니다.', + resolution: '0에서 99 사이의 숫자를 입력해주세요. (0 = 무제한)', + }, + E2001: { + userMessage: '봇에 채널을 관리할 권한이 부족합니다.', + resolution: '서버 관리자에게 봇의 "채널 관리" 권한을 확인해달라고 요청하세요.', + }, + // ... + }, + errorTitles: { + USER_INPUT: '입력을 확인해주세요', + PERMISSION: '권한이 부족합니다', + BOT_INTERNAL: '문제가 발생했습니다', + DISCORD_API: '일시적인 문제입니다', + }, + errorFields: { + resolution: '💡 해결 방법', + }, + voice: { + channelReady: '{{owner}}, 임시 채널이 준비되었습니다! 아래 드롭다운 메뉴로 관리하세요.', + defaultRoomName: '{{username}}의 방', + controlPanel: { + placeholder: '⚙️ 채널 설정 관리', + rename: '채널 이름 변경', + limit: '인원 제한 설정', + lock: '채널 잠금 / 해제', + kick: '유저 추방', + ban: '유저 차단 / 숨기기', + transfer: '소유권 이전', + }, + responses: { + channelLocked: '채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.', + channelUnlocked: '채널이 해제되었습니다! 누구나 참여할 수 있습니다.', + channelRenamed: '채널 이름이 **{{name}}**(으)로 변경되었습니다!', + limitSet: '인원 제한이 **{{limit}}**명으로 설정되었습니다!', + limitUnlimited: '무제한', + kicked: '{{user}}을(를) 채널에서 추방했습니다.', + banned: '{{user}}에게 채널을 숨기고 차단했습니다.', + transferPrompt: '채널의 새 소유자를 선택하세요.', + transferDone: '소유권이 {{user}}에게 이전되었습니다.', + banPrompt: '차단하면 해당 유저에게 채널이 보이지 않게 됩니다.', + }, + }, + commands: { + voiceSetup: { + setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!', + createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성 및 설정했습니다!', + }, + }, + modals: { + renameTitle: '음성 채널 이름 변경', + renameLabel: '새 채널 이름', + limitTitle: '인원 제한 설정', + limitLabel: '인원 제한 (0 = 무제한, 1-99)', + }, + selects: { + kickUser: '추방할 유저를 선택하세요', + banUser: '차단할 유저를 선택하세요', + transferOwner: '소유권을 이전할 유저를 선택하세요', + }, +} as const; +``` + +### 3-3. 핵심 모듈: `t()` 함수 + +```typescript +// src/i18n/index.ts — 개념 설계 + +import { en } from './locales/en'; +import { ko } from './locales/ko'; + +const locales = { en, ko }; +type SupportedLocale = keyof typeof locales; +const DEFAULT_LOCALE: SupportedLocale = 'en'; + +/** + * Resolve locale priority: + * 1. 사용자 DB 설정 + * 2. 서버 DB 설정 + * 3. Discord interaction.locale + * 4. fallback: 'en' + */ +function resolveLocale(options?: { + userLocale?: string | null; + guildLocale?: string | null; + discordLocale?: string; +}): SupportedLocale { + const candidates = [ + options?.userLocale, + options?.guildLocale, + options?.discordLocale?.split('-')[0], // 'ko-KR' → 'ko' + ]; + for (const candidate of candidates) { + if (candidate && candidate in locales) { + return candidate as SupportedLocale; + } + } + return DEFAULT_LOCALE; +} + +/** + * 번역 키로 현지화된 문자열을 가져옵니다. + * + * @param locale - 대상 locale + * @param key - 점 표기법 키 (예: 'voice.responses.channelLocked') + * @param vars - 템플릿 변수 (예: { user: '<@123>' }) + */ +function t( + locale: SupportedLocale, + key: string, + vars?: Record +): string { + // locale 번역에서 키 검색, 없으면 en fallback + let value = getNestedValue(locales[locale], key) + ?? getNestedValue(locales[DEFAULT_LOCALE], key) + ?? key; // 키 자체를 반환 (최후 fallback) + + // 템플릿 변수 치환: {{name}} → 실제 값 + if (vars) { + for (const [k, v] of Object.entries(vars)) { + value = value.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), v); + } + } + return value; +} +``` + +--- + +## 4. DB 스키마 확장 + +### 4-1. `GuildConfig`에 `locale` 필드 추가 + +```prisma +model GuildConfig { + guildId String @id + prefix String @default("!") + mimicEnabled Boolean @default(true) + locale String? // 서버 언어 설정 (e.g. 'ko', 'en') + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +### 4-2. `UserLocale` 모델 신규 생성 + +```prisma +model UserLocale { + userId String @id + locale String // 사용자 개인 언어 설정 (e.g. 'ko', 'en') + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +> [!NOTE] +> `UserLocale`을 별도 모델로 분리하는 이유: `UserVoiceProfile`은 음성 채널 전용이므로, +> 언어 설정은 봇 전체에 걸친 범용 설정으로 독립 관리합니다. + +--- + +## 5. 기존 시스템 통합 + +### 5-1. `ErrorCodes.ts` → i18n 전환 + +**Before** +```typescript +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.', +}), +``` + +**After** +```typescript +INVALID_USER_LIMIT: { + code: 'E1001', + category: ErrorCategory.USER_INPUT, + messageKey: 'errors.E1001', // i18n 키로 대체 +}, +``` + +`BotError` 클래스를 수정하여 `userMessage`/`resolution` 대신 `messageKey`를 보유하고, +`ErrorReporter`가 보고 시점에 locale을 결정하여 `t()` 함수로 메시지를 해석합니다. + +### 5-2. `ErrorReporter.ts` 변경 + +```typescript +// buildEmbed 메서드 변경 (개념) +private static buildEmbed(error: BotError, locale: SupportedLocale): EmbedBuilder { + const msg = t(locale, `${error.messageKey}.userMessage`); + const res = t(locale, `${error.messageKey}.resolution`); + const title = t(locale, `errorTitles.${error.category}`); + const fieldName = t(locale, 'errorFields.resolution'); + // ... Embed 빌드 +} +``` + +### 5-3. `interactionCreate.ts` / `VoiceService.ts` 변경 + +하드코딩된 모든 사용자 문자열을 `t(locale, key, vars)` 호출로 교체합니다. + +```typescript +// Before +await interaction.reply({ content: 'Channel Locked!', ephemeral: true }); + +// After +await interaction.reply({ + content: t(locale, 'voice.responses.channelLocked'), + ephemeral: true, +}); +``` + +### 5-4. 슬래시 명령어 설명 현지화 (discord.js `setDescriptionLocalizations`) + +discord.js는 슬래시 명령어의 이름/설명에 대한 네이티브 다국어를 지원합니다: + +```typescript +new SlashCommandBuilder() + .setName('voice-setup') + .setDescription('Setup a generator voice channel for temporary channels.') + .setDescriptionLocalizations({ + ko: '임시 음성 채널을 위한 생성기 채널을 설정합니다.', + }) +``` + +> [!IMPORTANT] +> 명령어 이름(`setName`)은 Discord 정책상 영어만 허용됩니다. +> 설명(`setDescription`)과 옵션 설명만 현지화 가능합니다. + +--- + +## 6. 언어 변경 명령어 + +### `/language` 슬래시 명령어 + +``` +/language [scope] [locale] + scope: 'user' | 'server' + locale: 'en' | 'ko' +``` + +- `scope: user` → `UserLocale` 테이블에 개인 설정 저장 +- `scope: server` → `GuildConfig.locale` 업데이트 (관리자 전용) + +--- + +## 7. 구현 Phase + +### Phase 1 — i18n 인프라 구축 +- `src/i18n/` 디렉터리 생성 (index.ts, types.ts) +- `src/i18n/locales/en.ts`, `ko.ts` 번역 파일 작성 +- `t()` 함수, `resolveLocale()` 함수 구현 +- DB 스키마 변경: `GuildConfig.locale` 추가, `UserLocale` 모델 생성 +- Prisma migrate 실행 + +### Phase 2 — 에러 시스템 i18n 통합 +- `BotError`, `ErrorCodes` 리팩터링: `userMessage`/`resolution` → `messageKey` +- `ErrorReporter.report()` / `buildEmbed()` 에 locale 파라미터 추가 +- `withErrorHandler`에 locale 전달 경로 추가 +- 기존 테스트 업데이트 + +### Phase 3 — UI/명령어 문자열 전환 +- `interactionCreate.ts` 모든 응답 메시지 `t()` 전환 +- `VoiceService.ts` 컨트롤 패널 및 응답 메시지 `t()` 전환 +- `voiceSetup.ts` 응답 메시지 `t()` 전환 +- 슬래시 명령어 설명 `setDescriptionLocalizations()` 적용 + +### Phase 4 — 언어 변경 명령어 +- `/language` 슬래시 명령어 구현 +- `UserLocale` / `GuildConfig.locale` CRUD + +### Phase 5 — 테스트 +- `t()` 함수 유닛 테스트 +- `resolveLocale()` 우선순위 유닛 테스트 +- 에러 시스템 통합 테스트 업데이트 +- 수동 테스트: Discord에서 `/language` 변경 후 응답 언어 확인 + +--- + +## 8. 검증 계획 (Verification Plan) + +### 자동 테스트 (Automated Tests) + +1. **i18n 핵심 모듈 유닛 테스트** — `tests/i18n/i18n.test.ts` + - `t()`: 정상 키 → 번역 값 반환 + - `t()`: 미존재 키 → en fallback + - `t()`: 미존재 키(en에도 없음) → 키 자체 반환 + - `t()`: 템플릿 변수 `{{var}}` 치환 검증 + - `resolveLocale()`: 우선순위 순서 검증 (user > guild > discord > default) + - 실행: `yarn test -- --testPathPattern='tests/i18n'` + +2. **에러 시스템 통합 테스트 업데이트** — `tests/errors/` + - `ErrorReporter.report()`에 locale 전달 시 해당 언어 Embed 생성 검증 + - 기존 `BotError.test.ts`, `ErrorReporter.test.ts` 수정 + - 실행: `yarn test -- --testPathPattern='tests/errors'` + +### 수동 테스트 (Manual Tests) + +1. 봇 개발 서버에서 `/language user ko` 실행 +2. `/voice-setup create test` 실행 → 한국어 응답 확인 +3. 음성 채널 참여 → 한국어 컨트롤 패널 확인 +4. 에러 유발 (잘못된 제한 입력 등) → 한국어 에러 Embed 확인 +5. `/language user en`으로 복원 → 영어 응답 복원 확인 + +--- + +## 9. 열린 질문 / 결정 필요 사항 + +> [!IMPORTANT] +> 구현 전 확정이 필요한 항목입니다. + +1. **번역 파일 형식**: TypeScript 객체(위 제안) vs JSON 파일 + - TS 객체: 타입 안전, IDE 지원 우수 / JSON: 비개발자 편집 용이 + - **제안**: TypeScript 객체 (현재 프로젝트 규모에 최적) + +2. **Discord `interaction.locale` 활용 범위** + - 3순위 fallback으로 사용 (사용자가 별도 설정하지 않은 경우 자동 감지) + - DB 설정이 있으면 Discord locale을 무시 → 맞습니까? + +3. **`/language` 명령어 권한** + - `user` scope: 모든 사용자 사용 가능 + - `server` scope: 관리자(Administrator) 전용 + - 이 정책이 맞습니까? + +4. **VoiceService 내부 로그 메시지**는 i18n 대상에서 **제외** (영어 고정) + - 서버 로그는 개발자/운영자 전용이므로 영어 유지 → 맞습니까? diff --git a/Docs/WorkDone/2026-03-27_i18n_Implementation.md b/Docs/WorkDone/2026-03-27_i18n_Implementation.md new file mode 100644 index 0000000..5eed8c2 --- /dev/null +++ b/Docs/WorkDone/2026-03-27_i18n_Implementation.md @@ -0,0 +1,40 @@ +# 2026-03-27: 다국어 지원(i18n) 구현 + +## 개요 +Kord 봇의 모든 사용자 표시 텍스트를 다국어로 제공하는 i18n 시스템을 구현했습니다. + +## 변경된 파일 + +### 신규 생성 +| 파일 | 설명 | +|------|------| +| `src/i18n/types.ts` | `I18nProvider` 인터페이스, `TranslationSchema`, `SupportedLocale` 타입 정의 | +| `src/i18n/index.ts` | `t()` 번역 함수, `resolveLocale()`, `StaticI18nProvider`, `setI18nProvider()` | +| `src/i18n/locales/en.ts` | 영어 번역 (전체 키 — 에러, 음성채널, 명령어, 모달, 셀렉트 메뉴) | +| `src/i18n/locales/ko.ts` | 한국어 번역 | +| `src/i18n/localeHelper.ts` | `getInteractionLocale()`, `getContextLocale()` 헬퍼 | +| `src/commands/language.ts` | `/language` 슬래시 명령어 (user/server scope) | +| `tests/i18n/i18n.test.ts` | i18n 유닛 테스트 (14 tests) | + +### 수정 +| 파일 | 변경 내용 | +|------|-----------| +| `src/errors/BotError.ts` | `userMessage`/`resolution` → `messageKey` (i18n 키) | +| `src/errors/ErrorCodes.ts` | `BotError` 인스턴스 → `ErrorDef` 경량 정의 객체 | +| `src/errors/ErrorReporter.ts` | `locale` 파라미터 추가, `t()` 함수로 메시지 해석 | +| `src/events/interactionCreate.ts` | 모든 하드코딩 문자열 → `t(locale, key, vars)` | +| `src/services/VoiceService.ts` | 컨트롤 패널, 채널명, 응답 → `t()` 사용 | +| `src/commands/voiceSetup.ts` | 응답 + `setDescriptionLocalizations()` 적용 | +| `prisma/schema.prisma` | `GuildConfig.locale` 추가, `UserLocale` 모델 신설 | + +## 아키텍처 결정 + +1. **I18nProvider 인터페이스**: `setI18nProvider()`로 런타임에 번역 백엔드 교체 가능 +2. **TypeScript 객체 기반 번역**: 타입 안전성 + IDE 자동완성 활용 +3. **Locale 우선순위**: 사용자 DB → 서버 DB → Discord Auto-detect → 영어 fallback +4. **서버 로그는 영어 고정**: 개발자/운영자 전용 + +## 검증 결과 +- **TypeScript 빌드**: 성공 (0 errors) +- **테스트**: 6 suites, 39 tests 전체 통과 +- **DB 마이그레이션**: `add-i18n-locale-support` 완료 diff --git a/Docs/index.md b/Docs/index.md index fa9e74c..fa82177 100644 --- a/Docs/index.md +++ b/Docs/index.md @@ -12,6 +12,7 @@ - [기능 로드맵 (Feature Roadmap)](Plans/Feature_Roadmap.md) - [임시 음성 채널 기능 기획서 (Temp Voice Channel Plan)](Plans/Temp_Voice_Channel_Plan.md) - [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md) +- [다국어 지원 기획서 (i18n Plan)](Plans/i18n_Plan.md) ## 아키텍처 및 정책 결정 (Decisions) - [구독 티어 시스템 설계 (Subscription Tiers)](Decisions/subscription_tiers.md) @@ -22,4 +23,5 @@ ## 진행/완료 내역 (Work Done) - [2026-03-27: 임시 음성 채널 기능 구현 (Temp Voice Channels Implementation)](WorkDone/2026-03-27_Voice_Channels_Implementation.md) +- [2026-03-27: 다국어 지원 구현 (i18n Implementation)](WorkDone/2026-03-27_i18n_Implementation.md) diff --git a/prisma/migrations/20260327051529_add_i18n_locale_support/migration.sql b/prisma/migrations/20260327051529_add_i18n_locale_support/migration.sql new file mode 100644 index 0000000..6f544da --- /dev/null +++ b/prisma/migrations/20260327051529_add_i18n_locale_support/migration.sql @@ -0,0 +1,108 @@ +-- CreateEnum +CREATE TYPE "SubscriptionTier" AS ENUM ('FREE', 'STANDARD', 'PRO', 'PREMIUM'); + +-- CreateEnum +CREATE TYPE "DeleteCondition" AS ENUM ('OWNER_LEAVE', 'EMPTY'); + +-- CreateTable +CREATE TABLE "GuildConfig" ( + "guildId" TEXT NOT NULL, + "prefix" TEXT NOT NULL DEFAULT '!', + "mimicEnabled" BOOLEAN NOT NULL DEFAULT true, + "locale" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "GuildConfig_pkey" PRIMARY KEY ("guildId") +); + +-- CreateTable +CREATE TABLE "InviteRole" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "inviteCode" TEXT NOT NULL, + "roleId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "InviteRole_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserSubscription" ( + "userId" TEXT NOT NULL, + "tier" "SubscriptionTier" NOT NULL DEFAULT 'FREE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserSubscription_pkey" PRIMARY KEY ("userId") +); + +-- CreateTable +CREATE TABLE "GuildOwnership" ( + "guildId" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GuildOwnership_pkey" PRIMARY KEY ("guildId") +); + +-- CreateTable +CREATE TABLE "VoiceGenerator" ( + "channelId" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "categoryId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VoiceGenerator_pkey" PRIMARY KEY ("channelId") +); + +-- CreateTable +CREATE TABLE "TempVoiceChannel" ( + "channelId" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "deleteWhen" "DeleteCondition" NOT NULL DEFAULT 'EMPTY', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TempVoiceChannel_pkey" PRIMARY KEY ("channelId") +); + +-- CreateTable +CREATE TABLE "UserVoiceProfile" ( + "userId" TEXT NOT NULL, + "customName" TEXT, + "userLimit" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserVoiceProfile_pkey" PRIMARY KEY ("userId") +); + +-- CreateTable +CREATE TABLE "UserLocale" ( + "userId" TEXT NOT NULL, + "locale" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserLocale_pkey" PRIMARY KEY ("userId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "InviteRole_guildId_inviteCode_key" ON "InviteRole"("guildId", "inviteCode"); + +-- CreateIndex +CREATE INDEX "GuildOwnership_ownerId_idx" ON "GuildOwnership"("ownerId"); + +-- CreateIndex +CREATE INDEX "VoiceGenerator_guildId_idx" ON "VoiceGenerator"("guildId"); + +-- CreateIndex +CREATE INDEX "TempVoiceChannel_guildId_idx" ON "TempVoiceChannel"("guildId"); + +-- CreateIndex +CREATE INDEX "TempVoiceChannel_ownerId_idx" ON "TempVoiceChannel"("ownerId"); + +-- AddForeignKey +ALTER TABLE "GuildOwnership" ADD CONSTRAINT "GuildOwnership_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "UserSubscription"("userId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..648c57f --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 59f6b97..a9c985c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,6 +11,7 @@ model GuildConfig { guildId String @id prefix String @default("!") mimicEnabled Boolean @default(true) + locale String? // Server locale override (e.g. 'ko', 'en') createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -82,3 +83,11 @@ model UserVoiceProfile { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model UserLocale { + userId String @id + locale String // User's personal locale (e.g. 'ko', 'en') + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + diff --git a/src/commands/language.ts b/src/commands/language.ts new file mode 100644 index 0000000..f4c3ab4 --- /dev/null +++ b/src/commands/language.ts @@ -0,0 +1,82 @@ +import { SlashCommandBuilder, ChatInputCommandInteraction, PermissionFlagsBits } from 'discord.js'; +import { prisma } from '../database'; +import { t, SupportedLocale, SUPPORTED_LOCALES } from '../i18n'; + +export default { + data: new SlashCommandBuilder() + .setName('language') + .setDescription('Set the language for the bot.') + .setDescriptionLocalizations({ + ko: '봇의 언어를 설정합니다.', + }) + .addStringOption(option => + option.setName('scope') + .setDescription('Apply to yourself or the entire server') + .setDescriptionLocalizations({ + ko: '본인에게만 또는 서버 전체에 적용', + }) + .setRequired(true) + .addChoices( + { name: 'Just for me', name_localizations: { ko: '나만 적용' }, value: 'user' }, + { name: 'Entire server (Admin only)', name_localizations: { ko: '서버 전체 (관리자 전용)' }, value: 'server' }, + ) + ) + .addStringOption(option => + option.setName('locale') + .setDescription('Language to use') + .setDescriptionLocalizations({ + ko: '사용할 언어', + }) + .setRequired(true) + .addChoices( + { name: 'English', value: 'en' }, + { name: '한국어', value: 'ko' }, + ) + ), + + async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { + const scope = interaction.options.getString('scope', true) as 'user' | 'server'; + const newLocale = interaction.options.getString('locale', true) as SupportedLocale; + + // Validate locale (safety check) + if (!SUPPORTED_LOCALES.includes(newLocale)) { + await interaction.reply({ content: `Unsupported locale: ${newLocale}`, ephemeral: true }); + return; + } + + if (scope === 'user') { + await prisma.userLocale.upsert({ + where: { userId: interaction.user.id }, + update: { locale: newLocale }, + create: { userId: interaction.user.id, locale: newLocale }, + }); + + // Respond in the NEWLY selected locale + await interaction.reply({ + content: t(newLocale, 'commands.language.userSet', { locale: newLocale === 'ko' ? '한국어' : 'English' }), + ephemeral: true, + }); + } else if (scope === 'server') { + // Require Administrator permission + if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { + await interaction.reply({ + content: t(locale, 'commands.language.serverPermissionDenied'), + ephemeral: true, + }); + return; + } + + await prisma.guildConfig.upsert({ + where: { guildId: interaction.guildId! }, + update: { locale: newLocale }, + create: { guildId: interaction.guildId!, locale: newLocale }, + }); + + // Respond in the NEWLY selected locale + await interaction.reply({ + content: t(newLocale, 'commands.language.serverSet', { locale: newLocale === 'ko' ? '한국어' : 'English' }), + ephemeral: true, + }); + } + }, +}; diff --git a/src/commands/voiceSetup.ts b/src/commands/voiceSetup.ts index 45cfb41..ec3a9c0 100644 --- a/src/commands/voiceSetup.ts +++ b/src/commands/voiceSetup.ts @@ -1,25 +1,38 @@ import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction, ChannelType } from 'discord.js'; import { prisma } from '../database'; -import { ErrorCodes, createBotError } from '../errors/ErrorCodes'; +import { SupportedLocale } from '../i18n'; +import { t } from '../i18n'; export default { data: new SlashCommandBuilder() .setName('voice-setup') .setDescription('Setup a generator voice channel for temporary channels.') + .setDescriptionLocalizations({ + ko: '임시 음성 채널을 위한 생성기 채널을 설정합니다.', + }) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .addSubcommand(subcommand => subcommand .setName('set') .setDescription('Set an existing voice channel as a Generator') + .setDescriptionLocalizations({ + ko: '기존 음성 채널을 생성기로 설정합니다', + }) .addChannelOption(option => option.setName('channel') .setDescription('The voice channel to act as the Generator') + .setDescriptionLocalizations({ + ko: '생성기로 사용할 음성 채널', + }) .setRequired(true) .addChannelTypes(ChannelType.GuildVoice) ) .addChannelOption(option => option.setName('category') .setDescription('(Optional) The category where temp channels will be created') + .setDescriptionLocalizations({ + ko: '(선택) 임시 채널이 생성될 카테고리', + }) .setRequired(false) .addChannelTypes(ChannelType.GuildCategory) ) @@ -28,20 +41,29 @@ export default { subcommand .setName('create') .setDescription('Create a new voice channel and set it as a Generator') + .setDescriptionLocalizations({ + ko: '새 음성 채널을 만들고 생성기로 설정합니다', + }) .addStringOption(option => option.setName('name') .setDescription('The name of the new generator voice channel') + .setDescriptionLocalizations({ + ko: '새 생성기 음성 채널의 이름', + }) .setRequired(true) ) .addChannelOption(option => option.setName('category') .setDescription('(Optional) The category where the new channel will be created') + .setDescriptionLocalizations({ + ko: '(선택) 새 채널이 생성될 카테고리', + }) .setRequired(false) .addChannelTypes(ChannelType.GuildCategory) ) ), - async execute(interaction: ChatInputCommandInteraction) { + async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { const subcommand = interaction.options.getSubcommand(); const category = interaction.options.getChannel('category'); @@ -59,7 +81,7 @@ export default { }); await interaction.reply({ - content: `Successfully set up ${channel} as a Voice Generator Channel!`, + content: t(locale, 'commands.voiceSetup.setSuccess', { channel: `${channel}` }), ephemeral: true }); } else if (subcommand === 'create') { @@ -81,7 +103,7 @@ export default { }); await interaction.reply({ - content: `Successfully created and set up ${newChannel} as a Voice Generator Channel!`, + content: t(locale, 'commands.voiceSetup.createSuccess', { channel: `${newChannel}` }), ephemeral: true }); } diff --git a/src/errors/BotError.ts b/src/errors/BotError.ts index 79edef1..579caf1 100644 --- a/src/errors/BotError.ts +++ b/src/errors/BotError.ts @@ -10,7 +10,8 @@ export enum ErrorCategory { /** * Custom error class for Kord bot. - * Contains structured error information for both user-facing messages and server logs. + * Uses i18n message keys instead of hardcoded strings. + * The actual translated message is resolved at report time by ErrorReporter. */ export class BotError extends Error { /** Error code for internal tracking (e.g. 'E2001') — NOT shown to users */ @@ -19,11 +20,8 @@ export class BotError extends Error { /** 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; + /** i18n key prefix for looking up translations (e.g. 'errors.E2001') */ + public readonly messageKey: string; /** Original error that caused this BotError */ public readonly cause?: Error; @@ -31,16 +29,14 @@ export class BotError extends Error { constructor(options: { code: string; category: ErrorCategory; - userMessage: string; - resolution?: string; + messageKey: string; cause?: Error; }) { - super(`[${options.code}] ${options.userMessage}`); + super(`[${options.code}] ${options.messageKey}`); this.name = 'BotError'; this.code = options.code; this.category = options.category; - this.userMessage = options.userMessage; - this.resolution = options.resolution; + this.messageKey = options.messageKey; this.cause = options.cause; } } diff --git a/src/errors/ErrorCodes.ts b/src/errors/ErrorCodes.ts index 8dcda7a..3f959cf 100644 --- a/src/errors/ErrorCodes.ts +++ b/src/errors/ErrorCodes.ts @@ -1,7 +1,9 @@ import { BotError, ErrorCategory } from './BotError'; /** - * Predefined error definitions for the Kord bot. + * Error code definitions — thin objects holding only the code, category, and i18n key. + * Actual user-facing messages live in src/i18n/locales/*.ts. + * * Error codes are prefixed by category: * E1xxx = USER_INPUT * E2xxx = PERMISSION @@ -10,135 +12,122 @@ import { BotError, ErrorCategory } from './BotError'; * * These codes are logged server-side only and NEVER exposed to end users. */ -export const ErrorCodes = { + +/** Error definition template (not a live BotError — used as factory input) */ +interface ErrorDef { + code: string; + category: ErrorCategory; + messageKey: string; +} + +export const ErrorDefs = { // ── USER_INPUT (E1xxx) ────────────────────────────────── - INVALID_USER_LIMIT: new BotError({ + INVALID_USER_LIMIT: { 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({ + messageKey: 'errors.E1001', + }, + INVALID_CHANNEL_NAME: { 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({ + messageKey: 'errors.E1002', + }, + SELF_TARGET_NOT_ALLOWED: { code: 'E1003', category: ErrorCategory.USER_INPUT, - userMessage: 'You cannot perform this action on yourself.', - }), - - USER_NOT_IN_CHANNEL: new BotError({ + messageKey: 'errors.E1003', + }, + USER_NOT_IN_CHANNEL: { 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.', - }), + messageKey: 'errors.E1004', + }, // ── PERMISSION (E2xxx) ────────────────────────────────── - BOT_MISSING_MANAGE_CHANNELS: new BotError({ + BOT_MISSING_MANAGE_CHANNELS: { 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({ + messageKey: 'errors.E2001', + }, + BOT_MISSING_VOICE_PERMISSIONS: { 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({ + messageKey: 'errors.E2002', + }, + USER_NOT_ADMIN: { 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({ + messageKey: 'errors.E2003', + }, + NOT_CHANNEL_OWNER: { code: 'E2004', category: ErrorCategory.PERMISSION, - userMessage: 'Only the channel owner can use these controls.', - }), - - MUST_BE_IN_VOICE_CHANNEL: new BotError({ + messageKey: 'errors.E2004', + }, + MUST_BE_IN_VOICE_CHANNEL: { 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.', - }), + messageKey: 'errors.E2005', + }, // ── BOT_INTERNAL (E3xxx) ──────────────────────────────── - DATABASE_ERROR: new BotError({ + DATABASE_ERROR: { 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({ + messageKey: 'errors.E3001', + }, + CACHE_ERROR: { 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({ + messageKey: 'errors.E3002', + }, + COMMAND_EXECUTION_FAILED: { 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; + messageKey: 'errors.E3003', + }, + UNKNOWN_ERROR: { + code: 'E3999', + category: ErrorCategory.BOT_INTERNAL, + messageKey: 'errors.E3999', + }, + + // ── DISCORD_API (E4xxx) ───────────────────────────────── + DISCORD_RATE_LIMITED: { + code: 'E4001', + category: ErrorCategory.DISCORD_API, + messageKey: 'errors.E4001', + }, + DISCORD_MISSING_PERMISSIONS: { + code: 'E4002', + category: ErrorCategory.DISCORD_API, + messageKey: 'errors.E4002', + }, + DISCORD_API_ERROR: { + code: 'E4003', + category: ErrorCategory.DISCORD_API, + messageKey: 'errors.E4003', + }, +} as const satisfies Record; /** - * 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. + * Backward-compatible alias. + * @deprecated Use ErrorDefs instead. + */ +export const ErrorCodes = ErrorDefs; + +/** + * Creates a new BotError from a predefined error definition, + * with an optional original cause error. */ export function createBotError( - template: BotError, + def: ErrorDef, cause?: Error, ): BotError { return new BotError({ - code: template.code, - category: template.category, - userMessage: template.userMessage, - resolution: template.resolution, + code: def.code, + category: def.category, + messageKey: def.messageKey, cause, }); } diff --git a/src/errors/ErrorReporter.ts b/src/errors/ErrorReporter.ts index a754846..11c599b 100644 --- a/src/errors/ErrorReporter.ts +++ b/src/errors/ErrorReporter.ts @@ -6,8 +6,9 @@ import { ModalSubmitInteraction, } from 'discord.js'; import { BotError, ErrorCategory } from './BotError'; -import { ErrorCodes, createBotError } from './ErrorCodes'; +import { ErrorDefs, createBotError } from './ErrorCodes'; import { logger } from '../utils/logger'; +import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n'; /** Embed color per error category */ const CATEGORY_COLORS: Record = { @@ -25,21 +26,13 @@ const CATEGORY_EMOJI: Record = { [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 = +export type RepliableInteraction = | ChatInputCommandInteraction | MessageComponentInteraction | ModalSubmitInteraction; /** - * Utility class for reporting errors as user-friendly Embeds. + * Utility class for reporting errors as user-friendly, localized Embeds. * Error codes and stack traces are logged server-side only. */ export class ErrorReporter { @@ -50,6 +43,7 @@ export class ErrorReporter { static async report( interaction: RepliableInteraction, error: BotError, + locale: SupportedLocale = DEFAULT_LOCALE, ): Promise { // ── Server-side logging (detailed) ── logger.error( @@ -57,8 +51,8 @@ export class ErrorReporter { error.cause ? error.cause : '', ); - // ── User-facing Embed (friendly, no error codes) ── - const embed = this.buildEmbed(error); + // ── User-facing Embed (friendly, localized, no error codes) ── + const embed = this.buildEmbed(error, locale); try { if (interaction.replied || interaction.deferred) { @@ -89,9 +83,9 @@ export class ErrorReporter { if (typeof discordCode === 'number') { switch (discordCode) { case 50013: - return createBotError(ErrorCodes.DISCORD_MISSING_PERMISSIONS, err); + return createBotError(ErrorDefs.DISCORD_MISSING_PERMISSIONS, err); case 50001: // Missing Access - return createBotError(ErrorCodes.BOT_MISSING_MANAGE_CHANNELS, err); + return createBotError(ErrorDefs.BOT_MISSING_MANAGE_CHANNELS, err); default: break; } @@ -99,31 +93,35 @@ export class ErrorReporter { // Map rate limit errors if ((error as any)?.httpStatus === 429) { - return createBotError(ErrorCodes.DISCORD_RATE_LIMITED, err); + return createBotError(ErrorDefs.DISCORD_RATE_LIMITED, err); } // Fallback — unknown internal error - return createBotError(ErrorCodes.UNKNOWN_ERROR, err); + return createBotError(ErrorDefs.UNKNOWN_ERROR, err); } /** - * Builds a user-friendly Embed from a BotError. + * Builds a user-friendly, localized Embed from a BotError. * Does NOT include error codes — those stay in logs only. */ - private static buildEmbed(error: BotError): EmbedBuilder { + private static buildEmbed(error: BotError, locale: SupportedLocale): EmbedBuilder { const emoji = CATEGORY_EMOJI[error.category]; - const title = CATEGORY_TITLE[error.category]; + const title = t(locale, `errorTitles.${error.category}`); + const userMessage = t(locale, `${error.messageKey}.userMessage`); + const resolution = t(locale, `${error.messageKey}.resolution`); + const fieldName = t(locale, 'errorFields.resolution'); const embed = new EmbedBuilder() .setColor(CATEGORY_COLORS[error.category]) .setTitle(`${emoji} ${title}`) - .setDescription(error.userMessage) + .setDescription(userMessage) .setTimestamp(); - if (error.resolution) { + // Only add resolution field if the translation exists (not the key itself) + if (resolution && resolution !== `${error.messageKey}.resolution`) { embed.addFields({ - name: '💡 How to resolve', - value: error.resolution, + name: fieldName, + value: resolution, }); } @@ -132,17 +130,18 @@ export class ErrorReporter { } /** - * Wraps an async handler function with standardized error handling. + * Wraps an async handler function with standardized, localized error handling. * Any thrown BotError is reported to the user; unknown errors are wrapped first. */ export async function withErrorHandler( interaction: RepliableInteraction, fn: () => Promise, + locale: SupportedLocale = DEFAULT_LOCALE, ): Promise { try { await fn(); } catch (error) { const botError = ErrorReporter.wrap(error); - await ErrorReporter.report(interaction, botError); + await ErrorReporter.report(interaction, botError, locale); } } diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index e96890d..35768fb 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -2,9 +2,11 @@ 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 { BotError } from '../errors/BotError'; +import { ErrorDefs, createBotError } from '../errors/ErrorCodes'; import { ErrorReporter, withErrorHandler } from '../errors/ErrorReporter'; +import { t } from '../i18n'; +import { getInteractionLocale } from '../i18n/localeHelper'; export default { name: Events.InteractionCreate, @@ -14,21 +16,23 @@ export default { const command = client.commands.get(interaction.commandName); if (!command) return; + const locale = await getInteractionLocale(interaction); await withErrorHandler(interaction, async () => { - await command.execute(interaction); - }); + await command.execute(interaction, locale); + }, locale); } else if (interaction.isStringSelectMenu()) { const customId = interaction.customId; if (customId.startsWith('vc_control_')) { + const locale = await getInteractionLocale(interaction); await withErrorHandler(interaction, async () => { const parts = customId.split('_'); const ownerId = parts[2]; const action = interaction.values[0]; if (interaction.user.id !== ownerId) { - throw createBotError(ErrorCodes.NOT_CHANNEL_OWNER); + throw createBotError(ErrorDefs.NOT_CHANNEL_OWNER); } const member = interaction.member as GuildMember; @@ -37,18 +41,18 @@ export default { if (!voiceChannel || interaction.channelId !== voiceChannel.id) { const tempDb = await prisma.tempVoiceChannel.findUnique({ where: { channelId: voiceChannel?.id || '' }}); if (!tempDb || tempDb.ownerId !== ownerId) { - throw createBotError(ErrorCodes.MUST_BE_IN_VOICE_CHANNEL); + throw createBotError(ErrorDefs.MUST_BE_IN_VOICE_CHANNEL); } } if (action === 'rename') { const modal = new ModalBuilder() .setCustomId(`modal_vc_rename_${ownerId}`) - .setTitle('Rename Voice Channel'); + .setTitle(t(locale, 'modals.renameTitle')); const nameInput = new TextInputBuilder() .setCustomId('newName') - .setLabel('New Channel Name') + .setLabel(t(locale, 'modals.renameLabel')) .setStyle(TextInputStyle.Short) .setValue(voiceChannel.name) .setRequired(true) @@ -60,11 +64,11 @@ export default { else if (action === 'limit') { const modal = new ModalBuilder() .setCustomId(`modal_vc_limit_${ownerId}`) - .setTitle('Set User Limit'); + .setTitle(t(locale, 'modals.limitTitle')); const limitInput = new TextInputBuilder() .setCustomId('limit') - .setLabel('User Limit (0 for unlimited, 1-99)') + .setLabel(t(locale, 'modals.limitLabel')) .setStyle(TextInputStyle.Short) .setValue(voiceChannel.userLimit.toString()) .setRequired(true) @@ -78,16 +82,16 @@ export default { const isLocked = everyonePerms?.deny.has(PermissionFlagsBits.Connect); if (isLocked) { await voiceChannel.permissionOverwrites.edit(voiceChannel.guild.id, { Connect: null }); - await interaction.reply({ content: 'Channel Unlocked! Anyone can join now.', ephemeral: true }); + await interaction.reply({ content: t(locale, 'voice.responses.channelUnlocked'), ephemeral: true }); } else { await voiceChannel.permissionOverwrites.edit(voiceChannel.guild.id, { Connect: false }); - await interaction.reply({ content: 'Channel Locked! Only you and invited members can join.', ephemeral: true }); + await interaction.reply({ content: t(locale, 'voice.responses.channelLocked'), ephemeral: true }); } } else if (action === 'kick') { const select = new UserSelectMenuBuilder() .setCustomId(`select_vc_kick_${ownerId}`) - .setPlaceholder('Select a user to kick') + .setPlaceholder(t(locale, 'selects.kickUser')) .setMinValues(1) .setMaxValues(1); await interaction.reply({ components: [new ActionRowBuilder().addComponents(select)], ephemeral: true }); @@ -95,38 +99,39 @@ export default { else if (action === 'ban') { const select = new UserSelectMenuBuilder() .setCustomId(`select_vc_ban_${ownerId}`) - .setPlaceholder('Select a user to ban/hide') + .setPlaceholder(t(locale, 'selects.banUser')) .setMinValues(1) .setMaxValues(1); - await interaction.reply({ content: 'Banning will make the channel invisible to them.', components: [new ActionRowBuilder().addComponents(select)], ephemeral: true }); + await interaction.reply({ content: t(locale, 'voice.responses.banPrompt'), components: [new ActionRowBuilder().addComponents(select)], ephemeral: true }); } else if (action === 'transfer') { const select = new UserSelectMenuBuilder() .setCustomId(`select_vc_transfer_${ownerId}`) - .setPlaceholder('Select a user to transfer ownership to') + .setPlaceholder(t(locale, 'selects.transferOwner')) .setMinValues(1) .setMaxValues(1); - await interaction.reply({ content: 'Select who will become the new owner of this channel.', components: [new ActionRowBuilder().addComponents(select)], ephemeral: true }); + await interaction.reply({ content: t(locale, 'voice.responses.transferPrompt'), components: [new ActionRowBuilder().addComponents(select)], ephemeral: true }); } - }); + }, locale); } } else if (interaction.isModalSubmit()) { const customId = interaction.customId; if (customId.startsWith('modal_vc_')) { + const locale = await getInteractionLocale(interaction); await withErrorHandler(interaction, async () => { const parts = customId.split('_'); const action = parts[2]; const ownerId = parts[3]; if (interaction.user.id !== ownerId) { - throw createBotError(ErrorCodes.NOT_CHANNEL_OWNER); + throw createBotError(ErrorDefs.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); + throw createBotError(ErrorDefs.MUST_BE_IN_VOICE_CHANNEL); } if (action === 'rename') { @@ -139,13 +144,13 @@ export default { create: { userId: ownerId, customName: newName } }); - await interaction.reply({ content: `Channel renamed to **${newName}**!`, ephemeral: true }); + await interaction.reply({ content: t(locale, 'voice.responses.channelRenamed', { name: newName }), ephemeral: true }); } else if (action === 'limit') { const limitStr = interaction.fields.getTextInputValue('limit'); const limit = parseInt(limitStr); if (isNaN(limit) || limit < 0 || limit > 99) { - throw createBotError(ErrorCodes.INVALID_USER_LIMIT); + throw createBotError(ErrorDefs.INVALID_USER_LIMIT); } await voiceChannel.setUserLimit(limit); @@ -155,41 +160,43 @@ export default { create: { userId: ownerId, userLimit: limit } }); - await interaction.reply({ content: `Channel limit set to **${limit === 0 ? 'Unlimited' : limit}**!`, ephemeral: true }); + const limitDisplay = limit === 0 ? t(locale, 'voice.responses.limitUnlimited') : String(limit); + await interaction.reply({ content: t(locale, 'voice.responses.limitSet', { limit: limitDisplay }), ephemeral: true }); } - }); + }, locale); } } else if (interaction.isUserSelectMenu()) { const customId = interaction.customId; if (customId.startsWith('select_vc_')) { + const locale = await getInteractionLocale(interaction); await withErrorHandler(interaction, async () => { const parts = customId.split('_'); const action = parts[2]; const ownerId = parts[3]; if (interaction.user.id !== ownerId) { - throw createBotError(ErrorCodes.NOT_CHANNEL_OWNER); + throw createBotError(ErrorDefs.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); + throw createBotError(ErrorDefs.MUST_BE_IN_VOICE_CHANNEL); } const targetUserId = interaction.values[0]; if (targetUserId === ownerId) { - throw createBotError(ErrorCodes.SELF_TARGET_NOT_ALLOWED); + throw createBotError(ErrorDefs.SELF_TARGET_NOT_ALLOWED); } 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 }); + await interaction.reply({ content: t(locale, 'voice.responses.kicked', { user: `<@${targetUserId}>` }), ephemeral: true }); } else { - throw createBotError(ErrorCodes.USER_NOT_IN_CHANNEL); + throw createBotError(ErrorDefs.USER_NOT_IN_CHANNEL); } } else if (action === 'ban') { @@ -203,7 +210,7 @@ export default { await targetMember.voice.disconnect('Banned by channel owner'); } - await interaction.reply({ content: `Banned and hidden channel from <@${targetUserId}>.`, ephemeral: true }); + await interaction.reply({ content: t(locale, 'voice.responses.banned', { user: `<@${targetUserId}>` }), ephemeral: true }); } else if (action === 'transfer') { const targetMember = await interaction.guild?.members.fetch(targetUserId); @@ -215,12 +222,12 @@ export default { const { VoiceService } = require('../services/VoiceService'); await VoiceService.applyOwnershipTransfer(voiceChannel, ownerId, targetUserId); - await interaction.reply({ content: `Ownership successfully transferred to <@${targetUserId}>.`, ephemeral: true }); + await interaction.reply({ content: t(locale, 'voice.responses.transferDone', { user: `<@${targetUserId}>` }), ephemeral: true }); } else { - throw createBotError(ErrorCodes.USER_NOT_IN_CHANNEL); + throw createBotError(ErrorDefs.USER_NOT_IN_CHANNEL); } } - }); + }, locale); } } }, diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..9bed644 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,151 @@ +/** + * Kord i18n Core Module. + * + * Provides the `t()` translation function and `resolveLocale()` utility. + * Uses the I18nProvider interface so the backend can be swapped + * (e.g. from static TS files to DB/API-based provider). + */ + +import { + SupportedLocale, + SUPPORTED_LOCALES, + DEFAULT_LOCALE, + I18nProvider, + LocaleResolutionOptions, + TranslationSchema, +} from './types'; +import { en } from './locales/en'; +import { ko } from './locales/ko'; + +// ── Static Provider (Default) ────────────────────────────── + +const localeMap: Record = { en, ko }; + +/** + * Default translation provider backed by static TypeScript objects. + * Implements I18nProvider for future swap-ability. + */ +class StaticI18nProvider implements I18nProvider { + get(locale: SupportedLocale, key: string): string | undefined { + return getNestedValue(localeMap[locale], key); + } + + isSupported(locale: string): locale is SupportedLocale { + return SUPPORTED_LOCALES.includes(locale as SupportedLocale); + } + + getSupportedLocales(): readonly SupportedLocale[] { + return SUPPORTED_LOCALES; + } +} + +// ── Active Provider (swappable) ──────────────────────────── + +let activeProvider: I18nProvider = new StaticI18nProvider(); + +/** + * Replace the active translation provider. + * Call this at boot time to swap to a different backend. + */ +export function setI18nProvider(provider: I18nProvider): void { + activeProvider = provider; +} + +/** Get the active translation provider (for testing or advanced use). */ +export function getI18nProvider(): I18nProvider { + return activeProvider; +} + +// ── Locale Resolution ────────────────────────────────────── + +/** + * Determine the locale to use, with priority: + * 1. User DB setting + * 2. Guild DB setting + * 3. Discord interaction.locale (auto-detect) + * 4. Default ('en') + */ +export function resolveLocale(options?: LocaleResolutionOptions): SupportedLocale { + const candidates = [ + options?.userLocale, + options?.guildLocale, + normalizeDiscordLocale(options?.discordLocale), + ]; + + for (const candidate of candidates) { + if (candidate && activeProvider.isSupported(candidate)) { + return candidate as SupportedLocale; + } + } + + return DEFAULT_LOCALE; +} + +/** + * Normalize Discord locale strings like 'ko' or 'en-US' to our format. + * Discord sends BCP47 tags; we use the language part only. + */ +function normalizeDiscordLocale(locale?: string): string | undefined { + if (!locale) return undefined; + // 'en-US' → 'en', 'ko' → 'ko', 'zh-CN' → 'zh' + return locale.split('-')[0].toLowerCase(); +} + +// ── Translation Function ─────────────────────────────────── + +/** + * Get a translated string by dot-notation key. + * + * @param locale - Target locale + * @param key - Dot-notation key (e.g. 'voice.responses.channelLocked') + * @param vars - Template variables (e.g. { user: '<@123>' }) + * @returns Translated string with variables interpolated + * + * Fallback order: + * 1. Target locale value + * 2. Default locale (en) value + * 3. The key itself (last resort) + */ +export function t( + locale: SupportedLocale, + key: string, + vars?: Record, +): string { + let value = + activeProvider.get(locale, key) ?? + activeProvider.get(DEFAULT_LOCALE, key) ?? + key; + + if (vars) { + for (const [k, v] of Object.entries(vars)) { + value = value.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), v); + } + } + + return value; +} + +// ── Helpers ──────────────────────────────────────────────── + +/** + * Retrieve a nested value from an object using dot-notation key. + * e.g. getNestedValue(obj, 'voice.responses.channelLocked') + */ +function getNestedValue(obj: unknown, key: string): string | undefined { + const parts = key.split('.'); + let current: unknown = obj; + + for (const part of parts) { + if (current === null || current === undefined || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[part]; + } + + return typeof current === 'string' ? current : undefined; +} + +// ── Re-exports ───────────────────────────────────────────── + +export { SupportedLocale, DEFAULT_LOCALE, SUPPORTED_LOCALES } from './types'; +export type { I18nProvider, LocaleResolutionOptions, TranslationSchema } from './types'; diff --git a/src/i18n/localeHelper.ts b/src/i18n/localeHelper.ts new file mode 100644 index 0000000..663fd8d --- /dev/null +++ b/src/i18n/localeHelper.ts @@ -0,0 +1,84 @@ +/** + * Locale resolution helper for Discord interactions. + * + * Reads locale preference from DB (user → guild → Discord) and returns the resolved locale. + * This bridges Discord interactions with the i18n system. + */ + +import { Interaction } from 'discord.js'; +import { resolveLocale, SupportedLocale } from '../i18n'; +import { prisma } from '../database'; + +/** + * Resolve the best locale for a given interaction. + * + * Priority: + * 1. User DB setting (UserLocale) + * 2. Guild DB setting (GuildConfig.locale) + * 3. Discord interaction.locale (auto-detect) + * 4. Default: 'en' + */ +export async function getInteractionLocale(interaction: Interaction): Promise { + let userLocale: string | null = null; + let guildLocale: string | null = null; + + try { + // Fetch user locale preference + const userPref = await prisma.userLocale.findUnique({ + where: { userId: interaction.user.id }, + select: { locale: true }, + }); + userLocale = userPref?.locale ?? null; + + // Fetch guild locale preference + if (interaction.guildId) { + const guildConfig = await prisma.guildConfig.findUnique({ + where: { guildId: interaction.guildId }, + select: { locale: true }, + }); + guildLocale = guildConfig?.locale ?? null; + } + } catch { + // If DB lookup fails, fall through to Discord locale / default + } + + return resolveLocale({ + userLocale, + guildLocale, + discordLocale: interaction.locale, + }); +} + +/** + * Resolve locale for non-interaction contexts (e.g. VoiceService events). + * Uses guild + user IDs directly. + */ +export async function getContextLocale( + guildId?: string | null, + userId?: string | null, +): Promise { + let userLocale: string | null = null; + let guildLocale: string | null = null; + + try { + if (userId) { + const userPref = await prisma.userLocale.findUnique({ + where: { userId }, + select: { locale: true }, + }); + userLocale = userPref?.locale ?? null; + } + + if (guildId) { + const guildConfig = await prisma.guildConfig.findUnique({ + where: { guildId }, + select: { locale: true }, + }); + guildLocale = guildConfig?.locale ?? null; + } + } catch { + // Fall through to default + } + + return resolveLocale({ userLocale, guildLocale }); +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts new file mode 100644 index 0000000..7fc79ab --- /dev/null +++ b/src/i18n/locales/en.ts @@ -0,0 +1,152 @@ +import { TranslationSchema } from '../types'; + +/** + * English translations — the DEFAULT and FALLBACK locale. + * All keys MUST be present here. Other locales can omit keys to fallback to English. + */ +export const en: TranslationSchema = { + // ── Error Messages ────────────────────────────────────── + errors: { + E1001: { + userMessage: 'The user limit value is invalid.', + resolution: 'Please enter a number between 0 and 99. (0 = unlimited)', + }, + E1002: { + userMessage: 'The channel name format is invalid.', + resolution: 'Please enter a valid channel name (max 100 characters).', + }, + E1003: { + userMessage: 'You cannot perform this action on yourself.', + }, + E1004: { + userMessage: 'The selected user is not in the voice channel.', + resolution: 'Make sure the user is currently in the channel before performing this action.', + }, + E2001: { + userMessage: 'The bot does not have sufficient permissions to manage channels.', + resolution: 'Please ask a server administrator to grant the bot "Manage Channels" permission.', + }, + E2002: { + 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.', + }, + E2003: { + userMessage: 'You do not have permission to use this command.', + resolution: 'This command requires Administrator permission.', + }, + E2004: { + userMessage: 'Only the channel owner can use these controls.', + }, + E2005: { + userMessage: 'You must be in your active temporary voice channel to use this.', + resolution: 'Join your temporary voice channel and try again.', + }, + E3001: { + userMessage: 'An internal error occurred while processing your request.', + resolution: 'Please try again in a moment. If the issue persists, contact the bot administrator.', + }, + E3002: { + userMessage: 'An internal error occurred while processing your request.', + resolution: 'Please try again in a moment.', + }, + E3003: { + userMessage: 'An error occurred while executing this command.', + resolution: 'Please try again. If the problem persists, contact the bot administrator.', + }, + E3999: { + userMessage: 'An unexpected error occurred.', + resolution: 'Please try again later. If the problem continues, contact the bot administrator.', + }, + E4001: { + userMessage: 'The action was rate-limited by Discord.', + resolution: 'Please wait a moment and try again.', + }, + E4002: { + userMessage: 'Discord denied the action due to insufficient permissions.', + resolution: 'Please ask a server administrator to check the bot\'s role and channel permissions.', + }, + E4003: { + userMessage: 'A temporary issue occurred with Discord.', + resolution: 'Please try again shortly. Check Discord\'s status at https://discordstatus.com if the issue persists.', + }, + }, + + // ── Error Category Titles ─────────────────────────────── + errorTitles: { + USER_INPUT: 'Please check your input', + PERMISSION: 'Insufficient permissions', + BOT_INTERNAL: 'Something went wrong', + DISCORD_API: 'Temporary issue', + }, + + // ── Error Embed Field Labels ──────────────────────────── + errorFields: { + resolution: '💡 How to resolve', + }, + + // ── Voice Channel ─────────────────────────────────────── + voice: { + channelReady: '{{owner}}, your temporary channel is ready! Use the dropdown menu below to manage it.', + defaultRoomName: "{{username}}'s Room", + controlPanel: { + placeholder: '⚙️ Manage Channel Settings', + rename: 'Rename Channel', + limit: 'Set User Limit', + lock: 'Lock / Unlock', + kick: 'Kick User', + ban: 'Ban / Hide User', + transfer: 'Transfer Ownership', + }, + responses: { + channelLocked: 'Channel Locked! Only you and invited members can join.', + channelUnlocked: 'Channel Unlocked! Anyone can join now.', + channelRenamed: 'Channel renamed to **{{name}}**!', + limitSet: 'Channel limit set to **{{limit}}**!', + limitUnlimited: 'Unlimited', + kicked: 'Kicked {{user}} from the channel.', + banned: 'Banned and hidden channel from {{user}}.', + transferPrompt: 'Select who will become the new owner of this channel.', + transferDone: 'Ownership successfully transferred to {{user}}.', + banPrompt: 'Banning will make the channel invisible to them.', + }, + }, + + // ── Commands ──────────────────────────────────────────── + commands: { + voiceSetup: { + description: 'Setup a generator voice channel for temporary channels.', + setDescription: 'Set an existing voice channel as a Generator', + createDescription: 'Create a new voice channel and set it as a Generator', + channelOptionDescription: 'The voice channel to act as the Generator', + categoryOptionDescription: '(Optional) The category where temp channels will be created', + nameOptionDescription: 'The name of the new generator voice channel', + setSuccess: 'Successfully set up {{channel}} as a Voice Generator Channel!', + createSuccess: 'Successfully created and set up {{channel}} as a Voice Generator Channel!', + }, + language: { + description: 'Set the language for the bot.', + scopeDescription: 'Apply to yourself or the entire server', + localeDescription: 'Language to use', + scopeUser: 'Just for me', + scopeServer: 'Entire server (Admin only)', + userSet: 'Your personal language has been set to **{{locale}}**.', + serverSet: 'Server language has been set to **{{locale}}**.', + serverPermissionDenied: 'Only server administrators can change the server language.', + }, + }, + + // ── Modals ────────────────────────────────────────────── + modals: { + renameTitle: 'Rename Voice Channel', + renameLabel: 'New Channel Name', + limitTitle: 'Set User Limit', + limitLabel: 'User Limit (0 for unlimited, 1-99)', + }, + + // ── Select Menu Placeholders ──────────────────────────── + selects: { + kickUser: 'Select a user to kick', + banUser: 'Select a user to ban/hide', + transferOwner: 'Select a user to transfer ownership to', + }, +}; diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts new file mode 100644 index 0000000..5f4155f --- /dev/null +++ b/src/i18n/locales/ko.ts @@ -0,0 +1,152 @@ +import { TranslationSchema } from '../types'; + +/** + * 한국어 번역 파일. + * 모든 키가 en.ts와 1:1 대응되어야 합니다. + */ +export const ko: TranslationSchema = { + // ── 에러 메시지 ───────────────────────────────────────── + errors: { + E1001: { + userMessage: '사용자 제한 값이 올바르지 않습니다.', + resolution: '0에서 99 사이의 숫자를 입력해주세요. (0 = 무제한)', + }, + E1002: { + userMessage: '채널 이름 형식이 올바르지 않습니다.', + resolution: '유효한 채널 이름을 입력해주세요. (최대 100자)', + }, + E1003: { + userMessage: '자기 자신에게는 이 작업을 수행할 수 없습니다.', + }, + E1004: { + userMessage: '선택한 유저가 음성 채널에 없습니다.', + resolution: '작업을 수행하기 전에 해당 유저가 채널에 있는지 확인해주세요.', + }, + E2001: { + userMessage: '봇에 채널을 관리할 권한이 부족합니다.', + resolution: '서버 관리자에게 봇의 "채널 관리" 권한을 확인해달라고 요청하세요.', + }, + E2002: { + userMessage: '봇에 음성 채널 관련 권한이 부족합니다.', + resolution: '서버 관리자에게 봇의 "채널 관리", "역할 관리", "멤버 이동" 권한을 확인해달라고 요청하세요.', + }, + E2003: { + userMessage: '이 명령어를 사용할 권한이 없습니다.', + resolution: '이 명령어는 관리자 권한이 필요합니다.', + }, + E2004: { + userMessage: '채널 소유자만 이 기능을 사용할 수 있습니다.', + }, + E2005: { + userMessage: '활성된 임시 음성 채널에 참여 중이어야 사용할 수 있습니다.', + resolution: '임시 음성 채널에 참여한 뒤 다시 시도해주세요.', + }, + E3001: { + userMessage: '요청을 처리하는 중 내부 오류가 발생했습니다.', + resolution: '잠시 후 다시 시도해주세요. 문제가 지속되면 봇 관리자에게 문의하세요.', + }, + E3002: { + userMessage: '요청을 처리하는 중 내부 오류가 발생했습니다.', + resolution: '잠시 후 다시 시도해주세요.', + }, + E3003: { + userMessage: '명령어를 실행하는 중 오류가 발생했습니다.', + resolution: '다시 시도해주세요. 문제가 지속되면 봇 관리자에게 문의하세요.', + }, + E3999: { + userMessage: '예상치 못한 오류가 발생했습니다.', + resolution: '나중에 다시 시도해주세요. 문제가 계속되면 봇 관리자에게 문의하세요.', + }, + E4001: { + userMessage: 'Discord에 의해 요청이 제한되었습니다.', + resolution: '잠시 기다린 후 다시 시도해주세요.', + }, + E4002: { + userMessage: '권한 부족으로 Discord가 작업을 거부했습니다.', + resolution: '서버 관리자에게 봇의 역할 및 채널 권한을 확인해달라고 요청하세요.', + }, + E4003: { + userMessage: 'Discord에 일시적인 문제가 발생했습니다.', + resolution: '잠시 후 다시 시도해주세요. 문제가 지속되면 https://discordstatus.com 에서 상태를 확인해주세요.', + }, + }, + + // ── 에러 카테고리 타이틀 ──────────────────────────────── + errorTitles: { + USER_INPUT: '입력을 확인해주세요', + PERMISSION: '권한이 부족합니다', + BOT_INTERNAL: '문제가 발생했습니다', + DISCORD_API: '일시적인 문제입니다', + }, + + // ── 에러 Embed 필드 라벨 ──────────────────────────────── + errorFields: { + resolution: '💡 해결 방법', + }, + + // ── 음성 채널 ─────────────────────────────────────────── + voice: { + channelReady: '{{owner}}, 임시 채널이 준비되었습니다! 아래 드롭다운 메뉴로 관리하세요.', + defaultRoomName: '{{username}}의 방', + controlPanel: { + placeholder: '⚙️ 채널 설정 관리', + rename: '채널 이름 변경', + limit: '인원 제한 설정', + lock: '채널 잠금 / 해제', + kick: '유저 추방', + ban: '유저 차단 / 숨기기', + transfer: '소유권 이전', + }, + responses: { + channelLocked: '채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.', + channelUnlocked: '채널이 해제되었습니다! 누구나 참여할 수 있습니다.', + channelRenamed: '채널 이름이 **{{name}}**(으)로 변경되었습니다!', + limitSet: '인원 제한이 **{{limit}}**명으로 설정되었습니다!', + limitUnlimited: '무제한', + kicked: '{{user}}을(를) 채널에서 추방했습니다.', + banned: '{{user}}에게 채널을 숨기고 차단했습니다.', + transferPrompt: '채널의 새 소유자를 선택하세요.', + transferDone: '소유권이 {{user}}에게 이전되었습니다.', + banPrompt: '차단하면 해당 유저에게 채널이 보이지 않게 됩니다.', + }, + }, + + // ── 명령어 ────────────────────────────────────────────── + commands: { + voiceSetup: { + description: '임시 음성 채널을 위한 생성기 채널을 설정합니다.', + setDescription: '기존 음성 채널을 생성기로 설정합니다', + createDescription: '새 음성 채널을 만들고 생성기로 설정합니다', + channelOptionDescription: '생성기로 사용할 음성 채널', + categoryOptionDescription: '(선택) 임시 채널이 생성될 카테고리', + nameOptionDescription: '새 생성기 음성 채널의 이름', + setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!', + createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성 및 설정했습니다!', + }, + language: { + description: '봇의 언어를 설정합니다.', + scopeDescription: '본인에게만 또는 서버 전체에 적용', + localeDescription: '사용할 언어', + scopeUser: '나만 적용', + scopeServer: '서버 전체 (관리자 전용)', + userSet: '개인 언어가 **{{locale}}**(으)로 설정되었습니다.', + serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.', + serverPermissionDenied: '서버 언어 변경은 서버 관리자만 가능합니다.', + }, + }, + + // ── 모달 ──────────────────────────────────────────────── + modals: { + renameTitle: '음성 채널 이름 변경', + renameLabel: '새 채널 이름', + limitTitle: '인원 제한 설정', + limitLabel: '인원 제한 (0 = 무제한, 1-99)', + }, + + // ── 셀렉트 메뉴 플레이스홀더 ──────────────────────────── + selects: { + kickUser: '추방할 유저를 선택하세요', + banUser: '차단할 유저를 선택하세요', + transferOwner: '소유권을 이전할 유저를 선택하세요', + }, +}; diff --git a/src/i18n/types.ts b/src/i18n/types.ts new file mode 100644 index 0000000..f36d0c5 --- /dev/null +++ b/src/i18n/types.ts @@ -0,0 +1,137 @@ +/** + * i18n Type Definitions & Interfaces for Kord Bot. + * + * Designed with provider interface pattern for future infrastructure swap + * (e.g. from static TS files to DB-backed or API-based translation service). + */ + +// ── Supported Locales ────────────────────────────────────── + +export type SupportedLocale = 'en' | 'ko'; + +export const SUPPORTED_LOCALES: readonly SupportedLocale[] = ['en', 'ko'] as const; +export const DEFAULT_LOCALE: SupportedLocale = 'en'; + +// ── Translation Structure ────────────────────────────────── + +/** Error message translation shape */ +export interface ErrorMessageTranslation { + userMessage: string; + resolution?: string; +} + +/** Translation key structure — all user-facing strings */ +export interface TranslationSchema { + // ── Error Messages ── + errors: Record; + + // ── Error Category Titles ── + errorTitles: { + USER_INPUT: string; + PERMISSION: string; + BOT_INTERNAL: string; + DISCORD_API: string; + }; + + // ── Error Embed Field Labels ── + errorFields: { + resolution: string; + }; + + // ── Voice Channel ── + voice: { + channelReady: string; + defaultRoomName: string; + controlPanel: { + placeholder: string; + rename: string; + limit: string; + lock: string; + kick: string; + ban: string; + transfer: string; + }; + responses: { + channelLocked: string; + channelUnlocked: string; + channelRenamed: string; + limitSet: string; + limitUnlimited: string; + kicked: string; + banned: string; + transferPrompt: string; + transferDone: string; + banPrompt: string; + }; + }; + + // ── Commands ── + commands: { + voiceSetup: { + description: string; + setDescription: string; + createDescription: string; + channelOptionDescription: string; + categoryOptionDescription: string; + nameOptionDescription: string; + setSuccess: string; + createSuccess: string; + }; + language: { + description: string; + scopeDescription: string; + localeDescription: string; + scopeUser: string; + scopeServer: string; + userSet: string; + serverSet: string; + serverPermissionDenied: string; + }; + }; + + // ── Modals ── + modals: { + renameTitle: string; + renameLabel: string; + limitTitle: string; + limitLabel: string; + }; + + // ── Select Menu Placeholders ── + selects: { + kickUser: string; + banUser: string; + transferOwner: string; + }; +} + +// ── Provider Interface ───────────────────────────────────── + +/** + * Interface for translation providers. + * Implement this to swap out the translation backend. + * + * Default: StaticI18nProvider (TypeScript object-based) + * Future: DbI18nProvider, ApiI18nProvider, etc. + */ +export interface I18nProvider { + /** Get a translated string by dot-notation key */ + get(locale: SupportedLocale, key: string): string | undefined; + + /** Check if a locale is supported */ + isSupported(locale: string): locale is SupportedLocale; + + /** List all supported locales */ + getSupportedLocales(): readonly SupportedLocale[]; +} + +// ── Locale Resolution Options ────────────────────────────── + +export interface LocaleResolutionOptions { + /** User's personal locale setting from DB */ + userLocale?: string | null; + /** Guild's locale setting from DB */ + guildLocale?: string | null; + /** Discord client locale from interaction.locale */ + discordLocale?: string; +} diff --git a/src/services/VoiceService.ts b/src/services/VoiceService.ts index 16b1bb5..3cfd498 100644 --- a/src/services/VoiceService.ts +++ b/src/services/VoiceService.ts @@ -2,7 +2,9 @@ import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBu import { prisma } from '../database'; import { logger } from '../utils/logger'; import { redis } from '../cache'; -import { ErrorCodes } from '../errors/ErrorCodes'; +import { ErrorDefs } from '../errors/ErrorCodes'; +import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n'; +import { getContextLocale } from '../i18n/localeHelper'; export class VoiceService { public static async syncChannels(client: Client) { @@ -88,7 +90,7 @@ export class VoiceService { PermissionFlagsBits.ManageRoles, PermissionFlagsBits.MoveMembers ])) { - logger.error(`${ErrorCodes.BOT_MISSING_VOICE_PERMISSIONS.code}: Bot lacks required Voice permissions in guild ${guild.id}`); + logger.error(`${ErrorDefs.BOT_MISSING_VOICE_PERMISSIONS.code}: Bot lacks required Voice permissions in guild ${guild.id}`); return; } @@ -100,13 +102,16 @@ export class VoiceService { try { await member.voice.setChannel(existingTemp.channelId); } catch (e) { - logger.error(`${ErrorCodes.DISCORD_API_ERROR.code}: Could not move user to existing voice channel`, e); + logger.error(`${ErrorDefs.DISCORD_API_ERROR.code}: Could not move user to existing voice channel`, e); } return; } + // Resolve locale for this context + const locale = await getContextLocale(guild.id, member.id); + const profile = await prisma.userVoiceProfile.findUnique({ where: { userId: member.id }}); - const channelName = profile?.customName || `${member.user.username}'s Room`; + const channelName = profile?.customName || t(locale, 'voice.defaultRoomName', { username: member.user.username }); const userLimit = profile?.userLimit || 0; try { @@ -136,7 +141,7 @@ export class VoiceService { }); } catch (firstError: any) { if (firstError.code === 50013) { - logger.warn(`${ErrorCodes.DISCORD_MISSING_PERMISSIONS.code}: Missing Permissions when creating with overwrites. Bot perms: ${botMember?.permissions.bitfield.toString()}. Retrying without overwrites...`); + logger.warn(`${ErrorDefs.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, @@ -160,9 +165,9 @@ export class VoiceService { await member.voice.setChannel(newChannel); logger.info(`VoiceService: Created ${newChannel.name} for ${member.user.tag}`); - await this.sendControlPanel(newChannel, member.id); + await this.sendControlPanel(newChannel, member.id, locale); } catch (error) { - logger.error(`${ErrorCodes.UNKNOWN_ERROR.code}: VoiceService Join Error`, error); + logger.error(`${ErrorDefs.UNKNOWN_ERROR.code}: VoiceService Join Error`, error); } } @@ -198,7 +203,7 @@ export class VoiceService { await prisma.tempVoiceChannel.delete({ where: { channelId } }); logger.info(`VoiceService: Successfully deleted temp channel ${channel.name}`); } catch (error) { - logger.error(`${ErrorCodes.DISCORD_MISSING_PERMISSIONS.code}: Failed to delete channel ${channel.name}`, error); + logger.error(`${ErrorDefs.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. } } @@ -215,19 +220,18 @@ 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(`${ErrorCodes.UNKNOWN_ERROR.code}: VoiceService failed to auto-transfer ownership`, error); + logger.error(`${ErrorDefs.UNKNOWN_ERROR.code}: VoiceService failed to auto-transfer ownership`, error); } } } } public static async applyOwnershipTransfer(channel: VoiceChannel, oldOwnerId: string, newOwnerId: string) { + const locale = await getContextLocale(channel.guildId, newOwnerId); + const profile = await prisma.userVoiceProfile.findUnique({ where: { userId: newOwnerId }}); - const newName = profile?.customName || `<@${newOwnerId}>'s Room`; // discord limits name formats, we must use raw username - - // Fix: Voice channel names can't contain <@id>, so we must fetch the member const newMember = await channel.guild.members.fetch(newOwnerId); - const finalName = profile?.customName || `${newMember.user.username}'s Room`; + const finalName = profile?.customName || t(locale, 'voice.defaultRoomName', { username: newMember.user.username }); await channel.setName(finalName).catch(() => {}); @@ -241,28 +245,30 @@ export class VoiceService { await channel.permissionOverwrites.delete(oldOwnerId).catch(() => {}); // Send new control panel - await this.sendControlPanel(channel, newOwnerId); + await this.sendControlPanel(channel, newOwnerId, locale); } - public static async sendControlPanel(channel: VoiceChannel, ownerId: string) { + public static async sendControlPanel(channel: VoiceChannel, ownerId: string, locale?: SupportedLocale) { + // Resolve locale if not provided + const resolvedLocale = locale ?? await getContextLocale(channel.guildId, ownerId); const selectMenu = new StringSelectMenuBuilder() .setCustomId(`vc_control_${ownerId}`) - .setPlaceholder('⚙️ Manage Channel Settings') + .setPlaceholder(t(resolvedLocale, 'voice.controlPanel.placeholder')) .addOptions( - new StringSelectMenuOptionBuilder().setLabel('Rename Channel').setValue('rename').setEmoji('✏️'), - new StringSelectMenuOptionBuilder().setLabel('Set User Limit').setValue('limit').setEmoji('👥'), - new StringSelectMenuOptionBuilder().setLabel('Lock / Unlock').setValue('lock').setEmoji('🔒'), - new StringSelectMenuOptionBuilder().setLabel('Kick User').setValue('kick').setEmoji('👢'), - new StringSelectMenuOptionBuilder().setLabel('Ban / Hide User').setValue('ban').setEmoji('🚫'), - new StringSelectMenuOptionBuilder().setLabel('Transfer Ownership').setValue('transfer').setEmoji('👑') + new StringSelectMenuOptionBuilder().setLabel(t(resolvedLocale, 'voice.controlPanel.rename')).setValue('rename').setEmoji('✏️'), + new StringSelectMenuOptionBuilder().setLabel(t(resolvedLocale, 'voice.controlPanel.limit')).setValue('limit').setEmoji('👥'), + new StringSelectMenuOptionBuilder().setLabel(t(resolvedLocale, 'voice.controlPanel.lock')).setValue('lock').setEmoji('🔒'), + new StringSelectMenuOptionBuilder().setLabel(t(resolvedLocale, 'voice.controlPanel.kick')).setValue('kick').setEmoji('👢'), + new StringSelectMenuOptionBuilder().setLabel(t(resolvedLocale, 'voice.controlPanel.ban')).setValue('ban').setEmoji('🚫'), + new StringSelectMenuOptionBuilder().setLabel(t(resolvedLocale, 'voice.controlPanel.transfer')).setValue('transfer').setEmoji('👑') ); const row = new ActionRowBuilder().addComponents(selectMenu); try { await channel.send({ - content: `<@${ownerId}>, your temporary channel is ready! Use the dropdown menu below to manage it.`, + content: t(resolvedLocale, 'voice.channelReady', { owner: `<@${ownerId}>` }), components: [row] }).catch(() => {}); } catch (e) { diff --git a/tests/errors/BotError.test.ts b/tests/errors/BotError.test.ts index 2a16a4c..70b9a06 100644 --- a/tests/errors/BotError.test.ts +++ b/tests/errors/BotError.test.ts @@ -5,8 +5,7 @@ describe('BotError', () => { const error = new BotError({ code: 'E1001', category: ErrorCategory.USER_INPUT, - userMessage: 'Invalid input', - resolution: 'Please try again', + messageKey: 'errors.E1001', }); expect(error).toBeInstanceOf(Error); @@ -14,19 +13,17 @@ describe('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'); + expect(error.messageKey).toBe('errors.E1001'); + expect(error.message).toBe('[E1001] errors.E1001'); }); - it('should create a BotError without optional fields', () => { + it('should create a BotError without optional cause', () => { const error = new BotError({ code: 'E3999', category: ErrorCategory.BOT_INTERNAL, - userMessage: 'Something went wrong', + messageKey: 'errors.E3999', }); - expect(error.resolution).toBeUndefined(); expect(error.cause).toBeUndefined(); }); @@ -35,7 +32,7 @@ describe('BotError', () => { const error = new BotError({ code: 'E3001', category: ErrorCategory.BOT_INTERNAL, - userMessage: 'Internal error', + messageKey: 'errors.E3001', cause: originalError, }); diff --git a/tests/errors/ErrorReporter.test.ts b/tests/errors/ErrorReporter.test.ts index 6d8ac60..51fc043 100644 --- a/tests/errors/ErrorReporter.test.ts +++ b/tests/errors/ErrorReporter.test.ts @@ -85,7 +85,7 @@ describe('ErrorReporter', () => { const error = createBotError(ErrorCodes.NOT_CHANNEL_OWNER); - await ErrorReporter.report(mockInteraction, error); + await ErrorReporter.report(mockInteraction, error, 'en'); expect(mockInteraction.reply).toHaveBeenCalledTimes(1); expect(mockInteraction.followUp).not.toHaveBeenCalled(); @@ -111,13 +111,13 @@ describe('ErrorReporter', () => { const error = createBotError(ErrorCodes.DATABASE_ERROR); - await ErrorReporter.report(mockInteraction, error); + await ErrorReporter.report(mockInteraction, error, 'en'); expect(mockInteraction.followUp).toHaveBeenCalledTimes(1); expect(mockInteraction.reply).not.toHaveBeenCalled(); }); - it('should include resolution field in embed when available', async () => { + it('should include localized resolution field in embed when available', async () => { const mockInteraction = { replied: false, deferred: false, @@ -126,7 +126,7 @@ describe('ErrorReporter', () => { const error = createBotError(ErrorCodes.INVALID_USER_LIMIT); - await ErrorReporter.report(mockInteraction, error); + await ErrorReporter.report(mockInteraction, error, 'en'); const embed = mockInteraction.reply.mock.calls[0][0].embeds[0]; const embedJSON = embed.toJSON(); @@ -134,6 +134,24 @@ describe('ErrorReporter', () => { expect(embedJSON.fields[0].name).toBe('💡 How to resolve'); }); + it('should render Korean messages when locale is ko', 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, 'ko'); + + const embed = mockInteraction.reply.mock.calls[0][0].embeds[0]; + const embedJSON = embed.toJSON(); + expect(embedJSON.title).toContain('입력을 확인해주세요'); + expect(embedJSON.description).toBe('사용자 제한 값이 올바르지 않습니다.'); + expect(embedJSON.fields[0].name).toBe('💡 해결 방법'); + }); + it('should not throw even if reply fails', async () => { const mockInteraction = { replied: false, @@ -144,19 +162,19 @@ describe('ErrorReporter', () => { const error = createBotError(ErrorCodes.UNKNOWN_ERROR); // Should not throw - await expect(ErrorReporter.report(mockInteraction, error)).resolves.not.toThrow(); + await expect(ErrorReporter.report(mockInteraction, error, 'en')).resolves.not.toThrow(); }); }); }); describe('createBotError()', () => { - it('should create a fresh BotError instance from a template', () => { + it('should create a fresh BotError instance from a definition', () => { 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); + expect(error1.messageKey).toBe(error2.messageKey); }); it('should attach a cause error', () => { diff --git a/tests/i18n/i18n.test.ts b/tests/i18n/i18n.test.ts new file mode 100644 index 0000000..5fc96d6 --- /dev/null +++ b/tests/i18n/i18n.test.ts @@ -0,0 +1,159 @@ +import { t, resolveLocale, setI18nProvider, getI18nProvider } from '../../src/i18n'; +import type { I18nProvider, SupportedLocale } from '../../src/i18n'; + +describe('i18n Core', () => { + // Save and restore the original provider + let originalProvider: I18nProvider; + beforeAll(() => { + originalProvider = getI18nProvider(); + }); + afterAll(() => { + setI18nProvider(originalProvider); + }); + + describe('t() - Translation Function', () => { + it('should return English translation for a valid key', () => { + const result = t('en', 'voice.responses.channelLocked'); + expect(result).toBe('Channel Locked! Only you and invited members can join.'); + }); + + it('should return Korean translation for a valid key', () => { + const result = t('ko', 'voice.responses.channelLocked'); + expect(result).toBe('채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.'); + }); + + it('should fallback to English when key is missing in target locale', () => { + // en.ts has all keys, so simulate a missing key by testing a deep key + // All keys should exist in ko, so this test verifies the mechanism + const result = t('en', 'errorTitles.USER_INPUT'); + expect(result).toBe('Please check your input'); + }); + + it('should return the key itself when not found in any locale', () => { + const result = t('en', 'nonExistent.key.path'); + expect(result).toBe('nonExistent.key.path'); + }); + + it('should interpolate template variables {{var}}', () => { + const result = t('en', 'voice.channelReady', { owner: '<@123>' }); + expect(result).toBe('<@123>, your temporary channel is ready! Use the dropdown menu below to manage it.'); + }); + + it('should interpolate Korean template variables', () => { + const result = t('ko', 'voice.channelReady', { owner: '<@456>' }); + expect(result).toBe('<@456>, 임시 채널이 준비되었습니다! 아래 드롭다운 메뉴로 관리하세요.'); + }); + + it('should replace multiple occurrences of the same variable', () => { + // Test with a key that has a variable + const result = t('en', 'voice.defaultRoomName', { username: 'TestUser' }); + expect(result).toBe("TestUser's Room"); + }); + + it('should return Korean default room name', () => { + const result = t('ko', 'voice.defaultRoomName', { username: '테스터' }); + expect(result).toBe('테스터의 방'); + }); + + it('should handle error message lookups', () => { + const result = t('en', 'errors.E1001.userMessage'); + expect(result).toBe('The user limit value is invalid.'); + }); + + it('should handle Korean error message lookups', () => { + const result = t('ko', 'errors.E1001.userMessage'); + expect(result).toBe('사용자 제한 값이 올바르지 않습니다.'); + }); + + it('should handle error resolution lookups', () => { + const result = t('en', 'errors.E1001.resolution'); + expect(result).toBe('Please enter a number between 0 and 99. (0 = unlimited)'); + }); + }); + + describe('resolveLocale() - Locale Resolution', () => { + it('should return default "en" when no options given', () => { + const result = resolveLocale(); + expect(result).toBe('en'); + }); + + it('should prefer userLocale over guildLocale', () => { + const result = resolveLocale({ + userLocale: 'ko', + guildLocale: 'en', + discordLocale: 'en-US', + }); + expect(result).toBe('ko'); + }); + + it('should use guildLocale when userLocale is null', () => { + const result = resolveLocale({ + userLocale: null, + guildLocale: 'ko', + discordLocale: 'en-US', + }); + expect(result).toBe('ko'); + }); + + it('should use discordLocale when DB settings are null', () => { + const result = resolveLocale({ + userLocale: null, + guildLocale: null, + discordLocale: 'ko', + }); + expect(result).toBe('ko'); + }); + + it('should normalize Discord locale "en-US" to "en"', () => { + const result = resolveLocale({ + userLocale: null, + guildLocale: null, + discordLocale: 'en-US', + }); + expect(result).toBe('en'); + }); + + it('should fallback to "en" for unsupported locale', () => { + const result = resolveLocale({ + userLocale: 'fr', // Not supported + guildLocale: null, + discordLocale: 'ja', // Not supported + }); + expect(result).toBe('en'); + }); + }); + + describe('I18nProvider Interface - Swappable Provider', () => { + it('should allow swapping to a custom provider', () => { + const customProvider: I18nProvider = { + get: (locale: SupportedLocale, key: string) => { + if (key === 'test.key') return `Custom: ${locale}`; + return undefined; + }, + isSupported: (locale: string): locale is SupportedLocale => locale === 'en', + getSupportedLocales: () => ['en'] as const, + }; + + setI18nProvider(customProvider); + const result = t('en', 'test.key'); + expect(result).toBe('Custom: en'); + + // Restore original + setI18nProvider(originalProvider); + }); + + it('should fallback correctly with custom provider', () => { + const customProvider: I18nProvider = { + get: () => undefined, + isSupported: (locale: string): locale is SupportedLocale => locale === 'en', + getSupportedLocales: () => ['en'] as const, + }; + + setI18nProvider(customProvider); + const result = t('en', 'unknown.key'); + expect(result).toBe('unknown.key'); // Should return key as final fallback + + setI18nProvider(originalProvider); + }); + }); +});