feat: Implement internationalization (i18n) with locale support, localized error messages, and a new language command.

This commit is contained in:
이정수 2026-03-27 14:24:59 +09:00
parent 6851fe2333
commit b93255a2be
22 changed files with 1813 additions and 209 deletions

View File

@ -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 등)

491
Docs/Plans/i18n_Plan.md Normal file
View File

@ -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, string>
): 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 대상에서 **제외** (영어 고정)
- 서버 로그는 개발자/운영자 전용이므로 영어 유지 → 맞습니까?

View File

@ -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` 완료

View File

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

View File

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

View File

@ -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"

View File

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

82
src/commands/language.ts Normal file
View File

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

View File

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

View File

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

View File

@ -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<string, ErrorDef>;
/**
* 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,
});
}

View File

@ -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<ErrorCategory, number> = {
@ -25,21 +26,13 @@ const CATEGORY_EMOJI: Record<ErrorCategory, string> = {
[ErrorCategory.DISCORD_API]: '🔄',
};
/** Title per error category */
const CATEGORY_TITLE: Record<ErrorCategory, string> = {
[ErrorCategory.USER_INPUT]: 'Please check your input',
[ErrorCategory.PERMISSION]: 'Insufficient permissions',
[ErrorCategory.BOT_INTERNAL]: 'Something went wrong',
[ErrorCategory.DISCORD_API]: 'Temporary issue',
};
type RepliableInteraction =
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<void> {
// ── 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<void>,
locale: SupportedLocale = DEFAULT_LOCALE,
): Promise<void> {
try {
await fn();
} catch (error) {
const botError = ErrorReporter.wrap(error);
await ErrorReporter.report(interaction, botError);
await ErrorReporter.report(interaction, botError, locale);
}
}

View File

@ -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<UserSelectMenuBuilder>().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<UserSelectMenuBuilder>().addComponents(select)], ephemeral: true });
await interaction.reply({ content: t(locale, 'voice.responses.banPrompt'), components: [new ActionRowBuilder<UserSelectMenuBuilder>().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<UserSelectMenuBuilder>().addComponents(select)], ephemeral: true });
await interaction.reply({ content: t(locale, 'voice.responses.transferPrompt'), components: [new ActionRowBuilder<UserSelectMenuBuilder>().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);
}
}
},

151
src/i18n/index.ts Normal file
View File

@ -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<SupportedLocale, TranslationSchema> = { 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, string>,
): 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<string, unknown>)[part];
}
return typeof current === 'string' ? current : undefined;
}
// ── Re-exports ─────────────────────────────────────────────
export { SupportedLocale, DEFAULT_LOCALE, SUPPORTED_LOCALES } from './types';
export type { I18nProvider, LocaleResolutionOptions, TranslationSchema } from './types';

84
src/i18n/localeHelper.ts Normal file
View File

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

152
src/i18n/locales/en.ts Normal file
View File

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

152
src/i18n/locales/ko.ts Normal file
View File

@ -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: '소유권을 이전할 유저를 선택하세요',
},
};

137
src/i18n/types.ts Normal file
View File

@ -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<string, ErrorMessageTranslation>;
// ── 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;
}

View File

@ -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 profile = await prisma.userVoiceProfile.findUnique({ where: { userId: newOwnerId }});
const newName = profile?.customName || `<@${newOwnerId}>'s Room`; // discord limits name formats, we must use raw username
const locale = await getContextLocale(channel.guildId, newOwnerId);
// Fix: Voice channel names can't contain <@id>, so we must fetch the member
const profile = await prisma.userVoiceProfile.findUnique({ where: { userId: newOwnerId }});
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<StringSelectMenuBuilder>().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) {

View File

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

View File

@ -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', () => {

159
tests/i18n/i18n.test.ts Normal file
View File

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