Merge branch 'main' into delete-redis
# Conflicts: # package.json # yarn.lock
This commit is contained in:
commit
1f91bfb9bf
|
|
@ -0,0 +1,314 @@
|
|||
# Kord - YouTube 음악 재생 기능 기획안 (YouTube Music Playback)
|
||||
|
||||
## 체인지로그 (Changelog)
|
||||
- **2026-03-30**: 최초 기획안 작성
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
YouTube 음악 재생 기능은 사용자가 텍스트 기반 명령으로 음악을 검색하거나 YouTube 링크를 입력하면,
|
||||
현재 참여 중인 음성 채팅방에 봇이 입장하여 재생 목록(플레이리스트)을 관리하고 오디오를 재생하는 기능입니다.
|
||||
|
||||
이 기능은 서버 유틸리티를 넘어서 실시간 상호작용 기능으로 확장되는 첫 사례이며,
|
||||
음성 연결, 큐 관리, UI 컨트롤, 외부 미디어 소스 처리까지 포함하는 비교적 큰 범위의 기능입니다.
|
||||
|
||||
### 목표
|
||||
|
||||
- 문자열 검색으로 YouTube 영상을 찾아 재생 목록에 추가할 수 있어야 함
|
||||
- YouTube 링크를 입력해 직접 재생 목록에 추가할 수 있어야 함
|
||||
- 현재 재생 목록을 조회할 수 있어야 함
|
||||
- 인덱스 기반으로 재생 목록 항목을 삭제할 수 있어야 함
|
||||
- 재생 중지 / 스킵 / 일시정지 / 재개 등 기본 컨트롤을 버튼 또는 이모지 기반 UI로 제공해야 함
|
||||
- 음악 추가 요청 시, 요청자가 있는 음성 채팅방에 봇이 자동 입장하고 재생을 시작해야 함
|
||||
- 마지막에 봇을 음성 채팅방에서 내보내는 기능이 있어야 함
|
||||
|
||||
---
|
||||
|
||||
## 2. 지원 범위 (MVP)
|
||||
|
||||
### 포함
|
||||
|
||||
- `/music add query:<문자열>`
|
||||
- `/music add url:<YouTube 링크>`
|
||||
- `/music queue`
|
||||
- `/music remove index:<번호>`
|
||||
- `/music skip`
|
||||
- `/music stop`
|
||||
- `/music leave`
|
||||
- 재생 컨트롤 메시지 (버튼 또는 이모지 라벨 기반)
|
||||
- 음성 채널 자동 입장 및 큐 기반 연속 재생
|
||||
|
||||
### 제외 (초기 범위 외)
|
||||
|
||||
- Spotify, SoundCloud 등 타 플랫폼 연동
|
||||
- 반복 재생 / 셔플 / 볼륨 조절
|
||||
- 길드별 DJ 역할 분리
|
||||
- 재생 이력 저장
|
||||
- 노래 가사 표시
|
||||
|
||||
---
|
||||
|
||||
## 3. 사용자 시나리오
|
||||
|
||||
### 시나리오 A: 검색어로 곡 추가
|
||||
|
||||
1. 사용자가 음성 채널에 입장한 상태에서 `/music add query:아이유 좋은날` 실행
|
||||
2. 봇이 검색 결과 1위를 선택하거나 선택 UI를 제공
|
||||
3. 재생 목록에 곡을 추가
|
||||
4. 봇이 음성 채널에 자동 입장
|
||||
5. 현재 재생 중이 없다면 즉시 재생 시작
|
||||
|
||||
### 시나리오 B: 링크로 곡 추가
|
||||
|
||||
1. 사용자가 `/music add url:https://www.youtube.com/watch?v=...` 실행
|
||||
2. 봇이 링크 메타데이터를 파싱
|
||||
3. 재생 목록에 항목 추가
|
||||
4. 이미 재생 중이면 큐 뒤에 대기
|
||||
|
||||
### 시나리오 C: 큐 조회 및 삭제
|
||||
|
||||
1. 사용자가 `/music queue` 실행
|
||||
2. 봇이 현재 재생곡과 대기열을 인덱스와 함께 Embed로 표시
|
||||
3. 사용자가 `/music remove index:3` 실행
|
||||
4. 3번 항목이 큐에서 제거됨
|
||||
|
||||
### 시나리오 D: 컨트롤 UI 사용
|
||||
|
||||
1. 재생 시작 시 봇이 "지금 재생 중" 메시지를 게시
|
||||
2. 메시지에는 ⏸️, ▶️, ⏭️, ⏹️ 같은 버튼이 포함됨
|
||||
3. 사용자가 버튼을 눌러 재생 상태를 제어
|
||||
|
||||
---
|
||||
|
||||
## 4. 명령 구조 제안
|
||||
|
||||
### `/music add`
|
||||
|
||||
입력 방식:
|
||||
|
||||
- `query`: YouTube 검색 문자열
|
||||
- `url`: YouTube 링크
|
||||
|
||||
규칙:
|
||||
|
||||
- 두 옵션 중 하나만 입력
|
||||
- 사용자는 반드시 음성 채널에 있어야 함
|
||||
|
||||
### `/music queue`
|
||||
|
||||
출력:
|
||||
|
||||
- 현재 재생 중 항목
|
||||
- 대기열 목록
|
||||
- 각 곡의 인덱스, 제목, 길이, 요청자
|
||||
|
||||
### `/music remove`
|
||||
|
||||
입력:
|
||||
|
||||
- `index`: 큐에서 제거할 번호
|
||||
|
||||
### `/music skip`
|
||||
|
||||
- 현재 곡을 스킵하고 다음 곡 재생
|
||||
|
||||
### `/music stop`
|
||||
|
||||
- 재생 중지
|
||||
- 현재 곡 정지 및 대기열 비움 여부는 정책 선택 필요
|
||||
|
||||
### `/music leave`
|
||||
|
||||
- 봇을 음성 채널에서 퇴장시킴
|
||||
- 기본 정책은 큐도 함께 정리
|
||||
|
||||
---
|
||||
|
||||
## 5. 재생 컨트롤 UI
|
||||
|
||||
### 컨트롤 메시지 구성
|
||||
|
||||
재생 시작 시 텍스트 채널에 Embed + 버튼 메시지 생성
|
||||
|
||||
권장 버튼:
|
||||
|
||||
- `⏸️` 일시정지
|
||||
- `▶️` 재개
|
||||
- `⏭️` 스킵
|
||||
- `⏹️` 정지
|
||||
- `📜` 큐 보기
|
||||
|
||||
### 정책
|
||||
|
||||
- 한 길드당 활성 컨트롤 메시지 1개 유지
|
||||
- 곡이 바뀔 때 동일 메시지를 업데이트하거나 새 메시지를 생성할지 결정 필요
|
||||
- MVP에서는 새 메시지 생성보다 기존 메시지 업데이트가 로그 노이즈를 줄이는 데 유리
|
||||
|
||||
---
|
||||
|
||||
## 6. 큐 및 재생 상태 설계
|
||||
|
||||
### 핵심 개념
|
||||
|
||||
- 재생 상태는 길드 단위로 분리
|
||||
- 각 길드는 하나의 음성 연결과 하나의 큐를 가짐
|
||||
- 큐는 메모리 기반으로 시작하고, 필요 시 DB 영속화 확장 가능
|
||||
|
||||
### 추천 구조
|
||||
|
||||
```ts
|
||||
interface MusicQueueItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
durationSec?: number;
|
||||
requestedByUserId: string;
|
||||
}
|
||||
|
||||
interface GuildMusicSession {
|
||||
guildId: string;
|
||||
voiceChannelId: string;
|
||||
textChannelId: string;
|
||||
nowPlaying: MusicQueueItem | null;
|
||||
queue: MusicQueueItem[];
|
||||
paused: boolean;
|
||||
controlMessageId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 초기 전략
|
||||
|
||||
- 메모리 기반 세션 관리
|
||||
- 봇 재시작 시 큐는 초기화됨
|
||||
- 안정화 이후 DB 저장 여부 검토
|
||||
|
||||
---
|
||||
|
||||
## 7. 음성 입장 및 재생 흐름
|
||||
|
||||
### 자동 입장
|
||||
|
||||
- `/music add` 실행 시 사용자의 음성 채널을 확인
|
||||
- 봇이 해당 채널에 없으면 자동 입장
|
||||
- 이미 다른 채널에 있으면 정책 필요
|
||||
|
||||
### 기본 정책 제안
|
||||
|
||||
- 같은 길드 내에서 봇이 이미 재생 중이면 다른 채널 요청은 거부
|
||||
- "현재 다른 음성 채널에서 사용 중" 메시지 제공
|
||||
|
||||
### 자동 퇴장
|
||||
|
||||
- `/music leave`로 수동 퇴장 가능
|
||||
- 추가 정책 후보:
|
||||
- 큐가 끝나고 1분 뒤 자동 퇴장
|
||||
- 음성 채널에 봇만 남으면 자동 퇴장
|
||||
|
||||
---
|
||||
|
||||
## 8. YouTube 검색 및 스트리밍 전략
|
||||
|
||||
> [!IMPORTANT]
|
||||
> YouTube 관련 기능은 라이브러리 안정성, 차단 이슈, 서비스 정책을 반드시 검토해야 합니다.
|
||||
|
||||
### 검색
|
||||
|
||||
가능한 접근:
|
||||
|
||||
- YouTube 공식 API 사용
|
||||
- 서드파티 검색 라이브러리 사용
|
||||
|
||||
### 오디오 스트리밍
|
||||
|
||||
가능한 접근:
|
||||
|
||||
- `@discordjs/voice` 기반 음성 재생
|
||||
- YouTube 스트림 추출 라이브러리 사용
|
||||
|
||||
### 기술 리스크
|
||||
|
||||
- YouTube 구조 변경 시 스트림 추출 라이브러리 고장 가능
|
||||
- 지역 제한 / 연령 제한 / 라이브 영상 처리 문제
|
||||
- 긴 재생 목록에서 메모리 및 연결 안정성 문제
|
||||
|
||||
---
|
||||
|
||||
## 9. 권한 및 운영 정책
|
||||
|
||||
### 봇 권한
|
||||
|
||||
- `Connect`
|
||||
- `Speak`
|
||||
- `View Channel`
|
||||
- 텍스트 채널의 `Send Messages`, `Embed Links`
|
||||
|
||||
### 사용자 권한
|
||||
|
||||
- 기본적으로 일반 사용자도 곡 추가 가능
|
||||
- `skip`, `stop`, `leave`, `remove`는 다음 중 하나로 제한 가능
|
||||
- 관리자 전용
|
||||
- 요청자 또는 관리자
|
||||
- 같은 음성 채널 참여자 전원 허용
|
||||
|
||||
### 추천 MVP 정책
|
||||
|
||||
- `add`, `queue`: 같은 음성 채널 참여자 누구나 가능
|
||||
- `skip`, `stop`, `remove`, `leave`: 관리자 또는 같은 음성 채널 참여자 허용
|
||||
|
||||
---
|
||||
|
||||
## 10. 에러 처리
|
||||
|
||||
필수 안내 케이스:
|
||||
|
||||
- 사용자가 음성 채널에 없음
|
||||
- 유효하지 않은 YouTube 링크
|
||||
- 검색 결과 없음
|
||||
- 재생 목록 인덱스 범위 오류
|
||||
- 봇 음성 권한 부족
|
||||
- 스트림 로드 실패
|
||||
|
||||
에러 메시지는 기존 Error Guidance 체계와 연결하는 것이 좋습니다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 구현 단계 (Phased Implementation)
|
||||
|
||||
| 단계 | 내용 |
|
||||
|------|------|
|
||||
| **Phase 1** | `@discordjs/voice` 기반 음성 연결, 메모리 큐, `/music add`, `/music queue`, `/music skip`, `/music leave` |
|
||||
| **Phase 2** | 링크 기반 추가 + 검색 기반 추가 분리, `/music remove`, `/music stop` |
|
||||
| **Phase 3** | 컨트롤 메시지(⏸️ ▶️ ⏭️ ⏹️) 및 상호작용 처리 |
|
||||
| **Phase 4** | 자동 퇴장, 권한 정책 세분화, 예외 처리 고도화 |
|
||||
| **Phase 5** | 반복 재생, 셔플, DJ 역할, 재생 이력 등 확장 기능 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 검증 계획
|
||||
|
||||
### 수동 테스트
|
||||
|
||||
1. 음성 채널 입장 후 검색어로 곡 추가
|
||||
2. 링크로 곡 추가
|
||||
3. 큐 조회 및 인덱스 삭제
|
||||
4. 스킵 / 정지 / 퇴장 동작 확인
|
||||
5. 곡 종료 후 다음 곡 자동 재생 확인
|
||||
6. 권한 부족 환경에서 적절한 에러 표시 확인
|
||||
|
||||
### 자동 테스트
|
||||
|
||||
- 큐 삽입 / 삭제 / 스킵 로직 단위 테스트
|
||||
- 길드별 세션 분리 테스트
|
||||
- 인덱스 유효성 검사 테스트
|
||||
- 컨트롤 인터랙션 핸들러 테스트
|
||||
|
||||
---
|
||||
|
||||
## 13. 관련 문서
|
||||
|
||||
| 문서 | 링크 |
|
||||
|------|------|
|
||||
| 기능 로드맵 | [`Feature_Roadmap.md`](./Feature_Roadmap.md) |
|
||||
| 임시 음성 채널 기획 | [`Temp_Voice_Channel_Plan.md`](./Temp_Voice_Channel_Plan.md) |
|
||||
| 에러 안내 기획 | [`Error_Guidance_Plan.md`](./Error_Guidance_Plan.md) |
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
# Kord - YouTube 음악 재생 Phase 1 구현
|
||||
|
||||
## 변경 개요
|
||||
|
||||
`@discordjs/voice + youtubei.js` 조합을 기준으로 YouTube 음악 재생 기능의 1차 구현을 추가했다.
|
||||
|
||||
이번 단계에서는 길드별 메모리 큐와 음성 연결 세션을 도입하고, 검색 또는 영상 URL로 곡을 추가해 바로 재생할 수 있는 최소 워크플로우를 구현했다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 범위
|
||||
|
||||
- `/music add`
|
||||
- `query`로 YouTube 검색 후 첫 번째 영상 추가
|
||||
- `url`로 YouTube 영상 링크 직접 추가
|
||||
- `/music queue`
|
||||
- 현재 재생 곡과 대기열 조회
|
||||
- `/music skip`
|
||||
- 현재 곡 스킵
|
||||
- `/music leave`
|
||||
- 봇 음성 채널 퇴장 및 큐 정리
|
||||
- 재생 컨트롤 버튼
|
||||
- `⏭ Skip`
|
||||
- `⏹ Stop`
|
||||
- `👋 Leave`
|
||||
|
||||
---
|
||||
|
||||
## 기술 구현
|
||||
|
||||
### 1. MusicService 추가
|
||||
|
||||
`src/services/MusicService.ts`
|
||||
|
||||
- 길드별 음악 세션을 메모리에서 관리
|
||||
- `youtubei.js`를 lazy import로 초기화
|
||||
- `getBasicInfo()`로 메타데이터 조회
|
||||
- `getInfo()`의 `server_abr_streaming_url` 또는 `hls_manifest_url`을 재생 소스로 사용
|
||||
- `ffmpeg-static`으로 원격 스트림을 PCM으로 디코딩
|
||||
- `@discordjs/voice` 플레이어에 연결
|
||||
|
||||
### 2. Slash Command 추가
|
||||
|
||||
`src/commands/music.ts`
|
||||
|
||||
- `/music add`
|
||||
- `/music queue`
|
||||
- `/music skip`
|
||||
- `/music leave`
|
||||
|
||||
### 3. 버튼 상호작용 연결
|
||||
|
||||
`src/events/interactionCreate.ts`
|
||||
|
||||
- 재생 메시지의 `music_*` 버튼 상호작용 처리 추가
|
||||
|
||||
### 4. i18n 반영
|
||||
|
||||
- `src/i18n/types.ts`
|
||||
- `src/i18n/locales/en.ts`
|
||||
- `src/i18n/locales/ko.ts`
|
||||
|
||||
음악 명령/재생 상태/오류 안내에 필요한 번역 키를 추가했다.
|
||||
|
||||
### 5. 테스트 추가
|
||||
|
||||
`tests/services/MusicService.test.ts`
|
||||
|
||||
- YouTube URL 파싱
|
||||
- 재생 시간 포맷팅
|
||||
|
||||
---
|
||||
|
||||
## 검증
|
||||
|
||||
- `yarn build`
|
||||
- `yarn test --runInBand`
|
||||
|
||||
두 명령 모두 정상 통과했다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 한계
|
||||
|
||||
- 이번 단계는 단일 영상 단위 큐 처리 기준이다.
|
||||
- YouTube 재생 목록 URL 전체 추가, 인덱스 삭제, 상세한 권한 제어는 아직 포함하지 않았다.
|
||||
- 스트림 URL은 `youtubei.js`의 현재 응답 구조에 의존하므로 향후 YouTube 응답 변경 시 보완이 필요할 수 있다.
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
# Kord - YouTube 음악 재생 Phase 2 구현
|
||||
|
||||
## 변경 개요
|
||||
|
||||
YouTube 음악 재생 기능의 Phase 2를 구현했다.
|
||||
|
||||
이번 단계에서는 재생 목록 관리 기능을 확장하고, 재생 중 메시지에 진행 바 UI를 추가해 현재 재생 상태를 더 직관적으로 볼 수 있도록 개선했다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 범위
|
||||
|
||||
- `/music remove index:<번호>`
|
||||
- 대기열에서 원하는 곡 삭제
|
||||
- `/music stop`
|
||||
- 현재 재생 중지 + 대기열 비우기
|
||||
- YouTube 재생목록 URL 추가
|
||||
- `list=` 파라미터가 있는 링크를 재생목록으로 인식
|
||||
- 최대 100곡까지 대기열에 추가
|
||||
- 재생 메시지 UI 개선
|
||||
- 현재 재생 시간
|
||||
- 전체 길이
|
||||
- 진행 바 텍스트 UI
|
||||
- 10초 간격 자동 갱신
|
||||
|
||||
---
|
||||
|
||||
## 기술 구현
|
||||
|
||||
### 1. MusicService 확장
|
||||
|
||||
`src/services/MusicService.ts`
|
||||
|
||||
- 길드 세션에 현재 곡 시작 시각(`nowPlayingStartedAt`)과 진행 바 갱신 인터벌(`progressInterval`) 추가
|
||||
- `yt-dlp --dump-single-json --flat-playlist`를 사용해 재생목록 URL 파싱
|
||||
- `remove()` 메서드 추가
|
||||
- 현재 곡 진행률 계산 및 텍스트 진행 바 생성
|
||||
- 재생 메시지를 10초 간격으로 업데이트
|
||||
|
||||
### 2. Slash Command 확장
|
||||
|
||||
`src/commands/music.ts`
|
||||
|
||||
- `/music remove`
|
||||
- `/music stop`
|
||||
- 재생목록 URL 응답 메시지 분기 추가
|
||||
|
||||
### 3. i18n 반영
|
||||
|
||||
- `src/i18n/types.ts`
|
||||
- `src/i18n/locales/en.ts`
|
||||
- `src/i18n/locales/ko.ts`
|
||||
|
||||
추가된 번역 키:
|
||||
- remove / stop 명령 설명
|
||||
- 인덱스 삭제 성공 / 범위 오류 안내
|
||||
- 재생목록 추가 성공 메시지
|
||||
- 진행 바 / 알 수 없는 길이 표기
|
||||
|
||||
### 4. 테스트 보강
|
||||
|
||||
`tests/services/MusicService.test.ts`
|
||||
|
||||
- YouTube 재생목록 URL 감지 테스트 추가
|
||||
|
||||
---
|
||||
|
||||
## 검증
|
||||
|
||||
- `yarn build`
|
||||
- `yarn test --runInBand`
|
||||
|
||||
두 명령 모두 정상 통과했다.
|
||||
|
||||
---
|
||||
|
||||
## 참고 사항
|
||||
|
||||
- 진행 바는 길이를 알 수 있는 곡에만 10초 간격으로 갱신된다.
|
||||
- 재생목록 URL은 최대 100곡까지 가져오도록 제한했다.
|
||||
- 현재 재생 중인 곡은 `/music remove` 대상이 아니며, 대기열 곡만 삭제된다.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# 2026-03-31 - YouTube Music Playback Phase 3 Implementation
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 3 focused on playback controls and now-playing UX improvements.
|
||||
|
||||
Implemented:
|
||||
- pause and resume controls
|
||||
- next-track preview in the now-playing message
|
||||
- immediate control message refresh after queue and button actions
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Pause / Resume
|
||||
|
||||
- Added `/music pause`
|
||||
- Added `/music resume`
|
||||
- Added pause/resume control button toggle in the playback message
|
||||
- Paused playback now freezes progress updates and resume restarts them
|
||||
|
||||
### 2. Next Track Preview
|
||||
|
||||
- The now-playing embed now shows the next queued track when one exists
|
||||
- Queue length and next-track display are refreshed when tracks are added or removed
|
||||
|
||||
### 3. Immediate UI Refresh
|
||||
|
||||
- Button actions now use deferred updates and refresh the control message immediately
|
||||
- Stop and leave actions move the playback message to the idle state without waiting for the periodic updater
|
||||
- Queue mutations such as `add` and `remove` also refresh the control message
|
||||
|
||||
## Files
|
||||
|
||||
- `src/commands/music.ts`
|
||||
- `src/services/MusicService.ts`
|
||||
- `src/i18n/types.ts`
|
||||
- `src/i18n/locales/en.ts`
|
||||
- `src/i18n/locales/ko.ts`
|
||||
|
||||
## Validation
|
||||
|
||||
- `yarn build`
|
||||
- `yarn test --runInBand`
|
||||
|
||||
Both commands completed successfully.
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
# 데이터베이스 테이블 구조
|
||||
|
||||
이 문서는 [Prisma 스키마](../prisma/schema.prisma)를 기준으로 한 **PostgreSQL** 테이블 구조 요약입니다.
|
||||
|
||||
## 개요
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| DB | PostgreSQL |
|
||||
| ORM | Prisma (`prisma-client-js`) |
|
||||
| 연결 | `DATABASE_URL` 환경 변수 |
|
||||
|
||||
## 열거형 (Enum)
|
||||
|
||||
### `SubscriptionTier`
|
||||
|
||||
구독 단계.
|
||||
|
||||
| 값 | 설명 |
|
||||
|----|------|
|
||||
| `FREE` | 기본 |
|
||||
| `STANDARD` | 스탠다드 |
|
||||
| `PRO` | 프로 |
|
||||
| `PREMIUM` | 프리미엄 |
|
||||
|
||||
### `DeleteCondition`
|
||||
|
||||
임시 음성 채널 삭제 조건.
|
||||
|
||||
| 값 | 설명 |
|
||||
|----|------|
|
||||
| `OWNER_LEAVE` | 소유자 퇴장 시 |
|
||||
| `EMPTY` | 비었을 때 (기본) |
|
||||
|
||||
### `EventStatus`
|
||||
|
||||
길드 이벤트 상태.
|
||||
|
||||
| 값 | 설명 |
|
||||
|----|------|
|
||||
| `SCHEDULED` | 예정 (기본) |
|
||||
| `CANCELLED` | 취소 |
|
||||
| `COMPLETED` | 완료 |
|
||||
|
||||
---
|
||||
|
||||
## 테이블 목록
|
||||
|
||||
### `GuildConfig`
|
||||
|
||||
디스코드 길드별 봇 설정.
|
||||
|
||||
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||
|------|------|----------------|
|
||||
| `guildId` | `String` | PK |
|
||||
| `prefix` | `String` | 기본 `!` |
|
||||
| `mimicEnabled` | `Boolean` | 기본 `false` |
|
||||
| `bigEmojiEnabled` | `Boolean` | 기본 `false` |
|
||||
| `locale` | `String?` | nullable |
|
||||
| `createdAt` | `DateTime` | 생성 시각 |
|
||||
| `updatedAt` | `DateTime` | 자동 갱신 |
|
||||
|
||||
---
|
||||
|
||||
### `InviteRole`
|
||||
|
||||
길드·초대 코드별 역할 매핑.
|
||||
|
||||
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||
|------|------|----------------|
|
||||
| `id` | `String` | PK, UUID |
|
||||
| `guildId` | `String` | |
|
||||
| `inviteCode` | `String` | |
|
||||
| `roleId` | `String` | |
|
||||
| `createdAt` | `DateTime` | |
|
||||
|
||||
**유니크:** `(guildId, inviteCode)`
|
||||
|
||||
---
|
||||
|
||||
### `UserSubscription`
|
||||
|
||||
사용자 구독 정보.
|
||||
|
||||
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||
|------|------|----------------|
|
||||
| `userId` | `String` | PK |
|
||||
| `tier` | `SubscriptionTier` | 기본 `FREE` |
|
||||
| `createdAt` | `DateTime` | |
|
||||
| `updatedAt` | `DateTime` | |
|
||||
|
||||
**관계:** `GuildOwnership[]` (1:N)
|
||||
|
||||
---
|
||||
|
||||
### `GuildOwnership`
|
||||
|
||||
구독 사용자가 소유하는 길드.
|
||||
|
||||
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||
|------|------|----------------|
|
||||
| `guildId` | `String` | PK |
|
||||
| `ownerId` | `String` | FK → `UserSubscription.userId`, `ON DELETE CASCADE` |
|
||||
| `createdAt` | `DateTime` | |
|
||||
|
||||
**인덱스:** `ownerId`
|
||||
|
||||
---
|
||||
|
||||
### `VoiceGenerator`
|
||||
|
||||
음성 채널 생성기(부모 채널) 설정.
|
||||
|
||||
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||
|------|------|----------------|
|
||||
| `channelId` | `String` | PK |
|
||||
| `guildId` | `String` | |
|
||||
| `categoryId` | `String?` | |
|
||||
| `createdAt` | `DateTime` | |
|
||||
| `updatedAt` | `DateTime` | |
|
||||
|
||||
**인덱스:** `guildId`
|
||||
|
||||
---
|
||||
|
||||
### `TempVoiceChannel`
|
||||
|
||||
생성된 임시 음성 채널.
|
||||
|
||||
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||
|------|------|----------------|
|
||||
| `channelId` | `String` | PK |
|
||||
| `guildId` | `String` | |
|
||||
| `ownerId` | `String` | |
|
||||
| `deleteWhen` | `DeleteCondition` | 기본 `EMPTY` |
|
||||
| `createdAt` | `DateTime` | |
|
||||
|
||||
**인덱스:** `guildId`, `ownerId`
|
||||
|
||||
---
|
||||
|
||||
### `UserVoiceProfile`
|
||||
|
||||
길드별 사용자 음성 프로필(표시 이름·인원 제한 등).
|
||||
|
||||
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||
|------|------|----------------|
|
||||
| `userId` | `String` | 복합 PK |
|
||||
| `guildId` | `String` | 복합 PK |
|
||||
| `customName` | `String?` | |
|
||||
| `userLimit` | `Int?` | |
|
||||
| `createdAt` | `DateTime` | |
|
||||
| `updatedAt` | `DateTime` | |
|
||||
|
||||
**PK:** `(userId, guildId)`
|
||||
|
||||
---
|
||||
|
||||
### `UserLocale`
|
||||
|
||||
사용자별 로케일.
|
||||
|
||||
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||
|------|------|----------------|
|
||||
| `userId` | `String` | PK |
|
||||
| `locale` | `String` | |
|
||||
| `createdAt` | `DateTime` | |
|
||||
| `updatedAt` | `DateTime` | |
|
||||
|
||||
---
|
||||
|
||||
### `AuditChannel`
|
||||
|
||||
감사 로그 전달 채널·비활성 카테고리.
|
||||
|
||||
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||
|------|------|----------------|
|
||||
| `guildId` | `String` | PK |
|
||||
| `channelId` | `String` | |
|
||||
| `disabledCategories` | `String[]` | 기본 `["BOOT", "SYSTEM"]` |
|
||||
| `createdAt` | `DateTime` | |
|
||||
| `updatedAt` | `DateTime` | |
|
||||
|
||||
---
|
||||
|
||||
### `VoiceGuildConfig`
|
||||
|
||||
길드 단위 음성(임시 채널) 기본 설정.
|
||||
|
||||
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||
|------|------|----------------|
|
||||
| `guildId` | `String` | PK |
|
||||
| `defaultNameTemplate` | `String` | 기본 `{{username}}'s Room` |
|
||||
| `defaultUserLimit` | `Int` | 기본 `0` |
|
||||
| `createdAt` | `DateTime` | |
|
||||
| `updatedAt` | `DateTime` | |
|
||||
|
||||
---
|
||||
|
||||
### `GuildEvent`
|
||||
|
||||
길드 일정/이벤트.
|
||||
|
||||
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||
|------|------|----------------|
|
||||
| `id` | `String` | PK, UUID |
|
||||
| `guildId` | `String` | |
|
||||
| `title` | `String` | |
|
||||
| `description` | `String?` | |
|
||||
| `startsAt` | `DateTime` | |
|
||||
| `timezone` | `String` | 기본 `Asia/Seoul` |
|
||||
| `status` | `EventStatus` | 기본 `SCHEDULED` |
|
||||
| `announcementChannelId` | `String?` | |
|
||||
| `createdByUserId` | `String` | |
|
||||
| `reminderEnabled` | `Boolean` | 기본 `true` |
|
||||
| `reminderOffsets` | `Int[]` | 기본 `[]` |
|
||||
| `sentReminderOffsets` | `Int[]` | 기본 `[]` |
|
||||
| `remindedOneHour` | `Boolean` | 기본 `false` |
|
||||
| `remindedTenMinutes` | `Boolean` | 기본 `false` |
|
||||
| `startedAnnounced` | `Boolean` | 기본 `false` |
|
||||
| `announcedAt` | `DateTime?` | |
|
||||
| `createdAt` | `DateTime` | |
|
||||
| `updatedAt` | `DateTime` | |
|
||||
|
||||
**인덱스:** `(guildId, startsAt)`, `(guildId, status)`
|
||||
|
||||
---
|
||||
|
||||
## 관계 요약
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
UserSubscription ||--o{ GuildOwnership : "userId"
|
||||
```
|
||||
|
||||
- **UserSubscription** 1 — N **GuildOwnership** (`ownerId` → `userId`, 길드 삭제 시 소유권 행 CASCADE 삭제)
|
||||
|
||||
그 외 테이블은 Prisma 모델상 **명시적 `relation` 블록**이 없으며, `guildId` / `userId` / `channelId` 등이 애플리케이션 레벨에서 Discord ID로 연결됩니다.
|
||||
|
||||
## 스키마 변경 시
|
||||
|
||||
실제 DDL은 `prisma/migrations/` 아래 마이그레이션 SQL과 동기화됩니다. 구조를 바꾼 뒤에는 이 문서와 [schema.prisma](../prisma/schema.prisma)를 함께 맞추는 것이 좋습니다.
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
- [에러 안내 기능 기획서 (Error Guidance Plan)](Plans/Error_Guidance_Plan.md)
|
||||
- [다국어 지원 기획서 (i18n Plan)](Plans/i18n_Plan.md)
|
||||
- [서버 이벤트 일정 관리 기능 기획안 (Event Schedule Management Plan)](Plans/Event_Schedule_Management_Plan.md)
|
||||
- [YouTube 鞚岇晠 鞛<>儩 旮半姤 旮绊殟鞎<E6AE9F> (YouTube Music Playback Plan)](Plans/YouTube_Music_Playback_Plan.md)
|
||||
|
||||
|
||||
## 아키텍처 및 정책 결정 (Decisions)
|
||||
|
|
@ -53,3 +54,6 @@
|
|||
- [2026-03-30: 서버 이벤트 시작 시점 공지 구현 (Event Schedule Start Announcement Implementation)](WorkDone/2026-03-30_Event_Schedule_Start_Announcement_Implementation.md)
|
||||
- [2026-03-30: 이벤트 리마인더 분 단위 옵션 구현 (Event Reminder Offsets Implementation)](WorkDone/2026-03-30_Event_Reminder_Offsets_Implementation.md)
|
||||
- [2026-03-30: 명령어 계층 구조 리팩토링 (Hierarchical Command Refactoring)](WorkDone/2026-03-30_HierarchicalRefactor.md)
|
||||
- [2026-03-30: YouTube 澜厩 犁积 Phase 1 备泅 (YouTube Music Playback Phase 1 Implementation)](WorkDone/2026-03-30_YouTube_Music_Playback_Phase1_Implementation.md)
|
||||
- [2026-03-31: YouTube 澜厩 犁积 Phase 2 备泅 (YouTube Music Playback Phase 2 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase2_Implementation.md)
|
||||
- [2026-03-31: YouTube 澜厩 犁积 Phase 3 备泅 (YouTube Music Playback Phase 3 Implementation)](WorkDone/2026-03-31_YouTube_Music_Playback_Phase3_Implementation.md)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,15 @@
|
|||
"name": "kord",
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"dependencies": {
|
||||
"@discordjs/opus": "^0.10.0",
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"@prisma/client": "6.4.1",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.3.1"
|
||||
"dotenv": "^17.3.1",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"prism-media": "^1.3.5",
|
||||
"youtubei.js": "^17.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,431 @@
|
|||
import { ChatInputCommandInteraction, GuildMember, SlashCommandBuilder } from 'discord.js';
|
||||
import { SupportedLocale, t } from '../i18n';
|
||||
import { MusicService } from '../services/MusicService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
function buildErrorMessage(locale: SupportedLocale, key: string) {
|
||||
const message = t(locale, `commands.music.${key}`);
|
||||
const resolution = t(locale, `commands.music.${key}Resolution`);
|
||||
return resolution && resolution !== `commands.music.${key}Resolution`
|
||||
? `${message}\n${resolution}`
|
||||
: message;
|
||||
}
|
||||
|
||||
async function respond(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
content: string,
|
||||
ephemeral = false,
|
||||
) {
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({ content });
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.replied) {
|
||||
await interaction.followUp({ content, ephemeral });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ content, ephemeral });
|
||||
}
|
||||
|
||||
export default {
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('music')
|
||||
.setDescription('Play YouTube audio in voice channels.')
|
||||
.setDescriptionLocalizations({
|
||||
ko: '음성 채널에서 YouTube 오디오를 재생합니다.',
|
||||
})
|
||||
.addSubcommand((subcommand) =>
|
||||
subcommand
|
||||
.setName('add')
|
||||
.setDescription('Search YouTube or add a video URL to the queue.')
|
||||
.setDescriptionLocalizations({
|
||||
ko: 'YouTube를 검색하거나 영상 URL을 재생 목록에 추가합니다.',
|
||||
})
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('query')
|
||||
.setDescription('Search query for YouTube')
|
||||
.setDescriptionLocalizations({
|
||||
ko: 'YouTube 검색어',
|
||||
}),
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName('url')
|
||||
.setDescription('YouTube video URL')
|
||||
.setDescriptionLocalizations({
|
||||
ko: 'YouTube 영상 URL',
|
||||
}),
|
||||
),
|
||||
)
|
||||
.addSubcommand((subcommand) =>
|
||||
subcommand
|
||||
.setName('queue')
|
||||
.setDescription('Show the current music queue.')
|
||||
.setDescriptionLocalizations({
|
||||
ko: '현재 음악 재생 목록을 표시합니다.',
|
||||
}),
|
||||
)
|
||||
.addSubcommand((subcommand) =>
|
||||
subcommand
|
||||
.setName('remove')
|
||||
.setDescription('Remove a track from the upcoming queue.')
|
||||
.setDescriptionLocalizations({
|
||||
ko: '대기열에서 원하는 곡을 삭제합니다.',
|
||||
})
|
||||
.addIntegerOption((option) =>
|
||||
option
|
||||
.setName('index')
|
||||
.setDescription('Queue index to remove')
|
||||
.setDescriptionLocalizations({
|
||||
ko: '삭제할 대기열 인덱스',
|
||||
})
|
||||
.setRequired(true)
|
||||
.setMinValue(1),
|
||||
),
|
||||
)
|
||||
.addSubcommand((subcommand) =>
|
||||
subcommand
|
||||
.setName('pause')
|
||||
.setDescription('Pause the currently playing track.')
|
||||
.setDescriptionLocalizations({
|
||||
ko: '현재 재생 중인 곡을 일시정지합니다.',
|
||||
}),
|
||||
)
|
||||
.addSubcommand((subcommand) =>
|
||||
subcommand
|
||||
.setName('resume')
|
||||
.setDescription('Resume the paused track.')
|
||||
.setDescriptionLocalizations({
|
||||
ko: '일시정지된 곡의 재생을 다시 시작합니다.',
|
||||
}),
|
||||
)
|
||||
.addSubcommand((subcommand) =>
|
||||
subcommand
|
||||
.setName('skip')
|
||||
.setDescription('Skip the currently playing track.')
|
||||
.setDescriptionLocalizations({
|
||||
ko: '현재 재생 중인 곡을 건너뜁니다.',
|
||||
}),
|
||||
)
|
||||
.addSubcommand((subcommand) =>
|
||||
subcommand
|
||||
.setName('stop')
|
||||
.setDescription('Stop playback and clear the queue.')
|
||||
.setDescriptionLocalizations({
|
||||
ko: '재생을 중지하고 대기열을 비웁니다.',
|
||||
}),
|
||||
)
|
||||
.addSubcommand((subcommand) =>
|
||||
subcommand
|
||||
.setName('leave')
|
||||
.setDescription('Disconnect the bot from the voice channel.')
|
||||
.setDescriptionLocalizations({
|
||||
ko: '봇을 음성 채널에서 내보냅니다.',
|
||||
}),
|
||||
),
|
||||
|
||||
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
try {
|
||||
if (subcommand === 'add') {
|
||||
const query = interaction.options.getString('query');
|
||||
const url = interaction.options.getString('url');
|
||||
|
||||
if ((!query && !url) || (query && url)) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'addMutuallyExclusive'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const textChannel = interaction.channel as any;
|
||||
if (!textChannel?.send) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'errors.E3003.userMessage'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply();
|
||||
|
||||
const result = query
|
||||
? await MusicService.addFromQuery(member, textChannel, query, locale)
|
||||
: await MusicService.addFromUrl(member, textChannel, url!, locale);
|
||||
|
||||
await interaction.editReply({
|
||||
content: result.tracksAdded > 1
|
||||
? (result.startedNow
|
||||
? t(locale, 'commands.music.playlistAddedNowPlaying', {
|
||||
count: String(result.tracksAdded),
|
||||
channel: `<#${result.voiceChannelId}>`,
|
||||
})
|
||||
: t(locale, 'commands.music.playlistAddedLater', {
|
||||
count: String(result.tracksAdded),
|
||||
}))
|
||||
: (result.startedNow
|
||||
? t(locale, 'commands.music.queueAddedNowPlaying', {
|
||||
title: result.track.title,
|
||||
channel: `<#${result.voiceChannelId}>`,
|
||||
})
|
||||
: t(locale, 'commands.music.queueAddedLater', {
|
||||
title: result.track.title,
|
||||
position: String(result.position),
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'queue') {
|
||||
await interaction.reply({
|
||||
embeds: [MusicService.getQueueEmbed(interaction.guildId!, locale)],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'remove') {
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const index = interaction.options.getInteger('index', true);
|
||||
const removed = await MusicService.remove(interaction.guildId!, index);
|
||||
if (!removed) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
content: t(locale, 'commands.music.queueRemoved', {
|
||||
title: removed.title,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'pause') {
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const paused = await MusicService.pause(interaction.guildId!, locale);
|
||||
if (!paused) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ content: t(locale, 'commands.music.pauseSuccess') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'resume') {
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const resumed = await MusicService.resume(interaction.guildId!, locale);
|
||||
if (!resumed) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ content: t(locale, 'commands.music.resumeSuccess') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'skip') {
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const skipped = await MusicService.skip(interaction.guildId!);
|
||||
if (!skipped) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ content: t(locale, 'commands.music.skipSuccess') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'stop') {
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stopped = await MusicService.stop(interaction.guildId!, locale);
|
||||
if (!stopped) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ content: t(locale, 'commands.music.stopSuccess') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'leave') {
|
||||
const member = interaction.member as GuildMember;
|
||||
if (!member.voice.channel) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'notInVoice'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeVoiceChannelId = MusicService.getActiveVoiceChannelId(interaction.guildId!);
|
||||
if (activeVoiceChannelId && activeVoiceChannelId !== member.voice.channelId) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'differentVoiceChannel'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const left = await MusicService.leave(interaction.guildId!, locale);
|
||||
if (!left) {
|
||||
await interaction.reply({
|
||||
content: buildErrorMessage(locale, 'noActiveSession'),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ content: t(locale, 'commands.music.leaveSuccess') });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in music command:', error);
|
||||
|
||||
const knownError = error instanceof Error ? error.message : '';
|
||||
if (knownError === 'NOT_IN_VOICE') {
|
||||
await respond(interaction, buildErrorMessage(locale, 'notInVoice'), true);
|
||||
return;
|
||||
}
|
||||
if (knownError === 'DIFFERENT_VOICE') {
|
||||
await respond(interaction, buildErrorMessage(locale, 'differentVoiceChannel'), true);
|
||||
return;
|
||||
}
|
||||
if (knownError === 'NO_SEARCH_RESULTS') {
|
||||
await respond(interaction, buildErrorMessage(locale, 'noSearchResults'), true);
|
||||
return;
|
||||
}
|
||||
if (knownError === 'INVALID_URL') {
|
||||
await respond(interaction, buildErrorMessage(locale, 'invalidUrl'), true);
|
||||
return;
|
||||
}
|
||||
if (knownError === 'QUEUE_INDEX_OUT_OF_RANGE') {
|
||||
await respond(interaction, buildErrorMessage(locale, 'queueRemoveOutOfRange'), true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await respond(interaction, t(locale, 'errors.E3003.userMessage'), true);
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
content: t(locale, 'errors.E3003.userMessage'),
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -8,6 +8,7 @@ import { ErrorReporter, withErrorHandler } from '../errors/ErrorReporter';
|
|||
import { t } from '../i18n';
|
||||
import { getInteractionLocale } from '../i18n/localeHelper';
|
||||
import { handleSetupWizardInteraction } from '../interactions/handlers/setupWizardHandler';
|
||||
import { MusicService } from '../services/MusicService';
|
||||
|
||||
export default {
|
||||
name: Events.InteractionCreate,
|
||||
|
|
@ -22,6 +23,12 @@ export default {
|
|||
await command.execute(interaction, locale);
|
||||
}, locale);
|
||||
}
|
||||
else if (interaction.isButton() && interaction.customId.startsWith('music_')) {
|
||||
const locale = await getInteractionLocale(interaction);
|
||||
await withErrorHandler(interaction, async () => {
|
||||
await MusicService.handleControlInteraction(interaction, locale);
|
||||
}, locale);
|
||||
}
|
||||
else if (interaction.isMessageComponent() && interaction.customId.startsWith('setup_')) {
|
||||
const locale = await getInteractionLocale(interaction);
|
||||
await withErrorHandler(interaction, async () => {
|
||||
|
|
|
|||
|
|
@ -191,6 +191,73 @@ export const en: TranslationSchema = {
|
|||
status: 'Status',
|
||||
},
|
||||
},
|
||||
music: {
|
||||
description: 'Play YouTube audio in voice channels.',
|
||||
addDescription: 'Search YouTube or add a video URL to the queue.',
|
||||
queueDescription: 'Show the current music queue.',
|
||||
removeDescription: 'Remove a track from the upcoming queue.',
|
||||
pauseDescription: 'Pause the currently playing track.',
|
||||
resumeDescription: 'Resume the paused track.',
|
||||
skipDescription: 'Skip the currently playing track.',
|
||||
stopDescription: 'Stop playback and clear the queue.',
|
||||
leaveDescription: 'Disconnect the bot from the voice channel.',
|
||||
queryDescription: 'Search query for YouTube',
|
||||
urlDescription: 'YouTube video URL',
|
||||
indexDescription: 'Queue index to remove',
|
||||
addMutuallyExclusive: 'Choose either a search query or a YouTube URL.',
|
||||
addMutuallyExclusiveResolution: 'Provide exactly one of `query` or `url`.',
|
||||
notInVoice: 'You must be in a voice channel to use music commands.',
|
||||
notInVoiceResolution: 'Join a voice channel first, then try again.',
|
||||
differentVoiceChannel: 'Music is already being used in another voice channel.',
|
||||
differentVoiceChannelResolution: 'Join the same voice channel as the bot or wait until the current session ends.',
|
||||
noSearchResults: 'No YouTube results were found for that query.',
|
||||
noSearchResultsResolution: 'Try a more specific search phrase or use a direct YouTube URL.',
|
||||
invalidUrl: 'The provided YouTube URL is invalid.',
|
||||
invalidUrlResolution: 'Use a standard `youtube.com` or `youtu.be` video link.',
|
||||
noActiveSession: 'There is no active music session in this server.',
|
||||
noActiveSessionResolution: 'Add a track first to start playback.',
|
||||
queueAddedNowPlaying: 'Added **{{title}}** and started playback in {{channel}}.',
|
||||
queueAddedLater: 'Added **{{title}}** to the queue. Position: `#{{position}}`.',
|
||||
playlistAddedNowPlaying: 'Added **{{count}}** tracks from the playlist and started playback in {{channel}}.',
|
||||
playlistAddedLater: 'Added **{{count}}** tracks from the playlist to the queue.',
|
||||
queueTitle: 'Music Queue',
|
||||
queueEmpty: 'The music queue is currently empty.',
|
||||
queueNowPlaying: 'Now Playing',
|
||||
queueUpcoming: 'Up Next',
|
||||
queueMoreItems: '...and **{{count}}** more track(s).',
|
||||
queueRemoved: 'Removed **{{title}}** from the queue.',
|
||||
queueRemoveOutOfRange: 'That queue index does not exist.',
|
||||
queueRemoveOutOfRangeResolution: 'Use `/music queue` to check the current queue indexes first.',
|
||||
pauseSuccess: 'Paused the current track.',
|
||||
resumeSuccess: 'Resumed playback.',
|
||||
skipSuccess: 'Skipped the current track.',
|
||||
leaveSuccess: 'Disconnected from the voice channel and cleared the queue.',
|
||||
stopSuccess: 'Stopped playback and cleared the queue.',
|
||||
playbackStartedTitle: 'Now Playing',
|
||||
playbackIdleTitle: 'Queue Finished',
|
||||
playbackIdleBody: 'There are no more tracks in the queue.',
|
||||
playbackFailed: 'Failed to play **{{title}}**. Skipping to the next track.',
|
||||
playbackFailedResolution: 'The stream could not be loaded from YouTube.',
|
||||
streamUnavailable: 'Could not load a playable audio stream for this video.',
|
||||
streamUnavailableResolution: 'Try another video or add the track again later.',
|
||||
requestedBy: 'Requested by',
|
||||
duration: 'Duration',
|
||||
progress: 'Progress',
|
||||
source: 'Source',
|
||||
status: 'Status',
|
||||
queueLength: 'Queue Length',
|
||||
nextTrack: 'Next Track',
|
||||
statusPlaying: 'Playing',
|
||||
statusPaused: 'Paused',
|
||||
unknownDuration: 'Unknown',
|
||||
buttons: {
|
||||
pause: '⏸ Pause',
|
||||
resume: '▶ Resume',
|
||||
skip: '⏭ Skip',
|
||||
stop: '⏹ Stop',
|
||||
leave: '👋 Leave',
|
||||
},
|
||||
},
|
||||
permissionAudit: {
|
||||
title: 'Bot Permission Audit Report',
|
||||
channel: 'Channel',
|
||||
|
|
|
|||
|
|
@ -191,6 +191,73 @@ export const ko: TranslationSchema = {
|
|||
status: '상태',
|
||||
},
|
||||
},
|
||||
music: {
|
||||
description: 'Play YouTube audio in voice channels.',
|
||||
addDescription: 'Search YouTube or add a video URL to the queue.',
|
||||
queueDescription: 'Show the current music queue.',
|
||||
removeDescription: 'Remove a track from the upcoming queue.',
|
||||
pauseDescription: 'Pause the currently playing track.',
|
||||
resumeDescription: 'Resume the paused track.',
|
||||
skipDescription: 'Skip the currently playing track.',
|
||||
stopDescription: 'Stop playback and clear the queue.',
|
||||
leaveDescription: 'Disconnect the bot from the voice channel.',
|
||||
queryDescription: 'Search query for YouTube',
|
||||
urlDescription: 'YouTube video URL',
|
||||
indexDescription: 'Queue index to remove',
|
||||
addMutuallyExclusive: 'Choose either a search query or a YouTube URL.',
|
||||
addMutuallyExclusiveResolution: 'Provide exactly one of `query` or `url`.',
|
||||
notInVoice: 'You must be in a voice channel to use music commands.',
|
||||
notInVoiceResolution: 'Join a voice channel first, then try again.',
|
||||
differentVoiceChannel: 'Music is already being used in another voice channel.',
|
||||
differentVoiceChannelResolution: 'Join the same voice channel as the bot or wait until the current session ends.',
|
||||
noSearchResults: 'No YouTube results were found for that query.',
|
||||
noSearchResultsResolution: 'Try a more specific search phrase or use a direct YouTube URL.',
|
||||
invalidUrl: 'The provided YouTube URL is invalid.',
|
||||
invalidUrlResolution: 'Use a standard `youtube.com` or `youtu.be` video link.',
|
||||
noActiveSession: 'There is no active music session in this server.',
|
||||
noActiveSessionResolution: 'Add a track first to start playback.',
|
||||
queueAddedNowPlaying: 'Added **{{title}}** and started playback in {{channel}}.',
|
||||
queueAddedLater: 'Added **{{title}}** to the queue. Position: `#{{position}}`.',
|
||||
playlistAddedNowPlaying: 'Added **{{count}}** tracks from the playlist and started playback in {{channel}}.',
|
||||
playlistAddedLater: 'Added **{{count}}** tracks from the playlist to the queue.',
|
||||
queueTitle: 'Music Queue',
|
||||
queueEmpty: 'The music queue is currently empty.',
|
||||
queueNowPlaying: 'Now Playing',
|
||||
queueUpcoming: 'Up Next',
|
||||
queueMoreItems: '...and **{{count}}** more track(s).',
|
||||
queueRemoved: 'Removed **{{title}}** from the queue.',
|
||||
queueRemoveOutOfRange: 'That queue index does not exist.',
|
||||
queueRemoveOutOfRangeResolution: 'Use `/music queue` to check the current queue indexes first.',
|
||||
pauseSuccess: 'Paused the current track.',
|
||||
resumeSuccess: 'Resumed playback.',
|
||||
skipSuccess: 'Skipped the current track.',
|
||||
leaveSuccess: 'Disconnected from the voice channel and cleared the queue.',
|
||||
stopSuccess: 'Stopped playback and cleared the queue.',
|
||||
playbackStartedTitle: 'Now Playing',
|
||||
playbackIdleTitle: 'Queue Finished',
|
||||
playbackIdleBody: 'There are no more tracks in the queue.',
|
||||
playbackFailed: 'Failed to play **{{title}}**. Skipping to the next track.',
|
||||
playbackFailedResolution: 'The stream could not be loaded from YouTube.',
|
||||
streamUnavailable: 'Could not load a playable audio stream for this video.',
|
||||
streamUnavailableResolution: 'Try another video or add the track again later.',
|
||||
requestedBy: 'Requested by',
|
||||
duration: 'Duration',
|
||||
progress: 'Progress',
|
||||
source: 'Source',
|
||||
status: 'Status',
|
||||
queueLength: 'Queue Length',
|
||||
nextTrack: 'Next Track',
|
||||
statusPlaying: 'Playing',
|
||||
statusPaused: 'Paused',
|
||||
unknownDuration: 'Unknown',
|
||||
buttons: {
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
skip: 'Skip',
|
||||
stop: 'Stop',
|
||||
leave: 'Leave',
|
||||
},
|
||||
},
|
||||
permissionAudit: {
|
||||
title: '봇 권한 진단 보고서',
|
||||
channel: '채널',
|
||||
|
|
|
|||
|
|
@ -145,6 +145,73 @@ export interface TranslationSchema {
|
|||
status: string;
|
||||
};
|
||||
};
|
||||
music: {
|
||||
description: string;
|
||||
addDescription: string;
|
||||
queueDescription: string;
|
||||
removeDescription: string;
|
||||
pauseDescription: string;
|
||||
resumeDescription: string;
|
||||
skipDescription: string;
|
||||
stopDescription: string;
|
||||
leaveDescription: string;
|
||||
queryDescription: string;
|
||||
urlDescription: string;
|
||||
indexDescription: string;
|
||||
addMutuallyExclusive: string;
|
||||
addMutuallyExclusiveResolution: string;
|
||||
notInVoice: string;
|
||||
notInVoiceResolution: string;
|
||||
differentVoiceChannel: string;
|
||||
differentVoiceChannelResolution: string;
|
||||
noSearchResults: string;
|
||||
noSearchResultsResolution: string;
|
||||
invalidUrl: string;
|
||||
invalidUrlResolution: string;
|
||||
noActiveSession: string;
|
||||
noActiveSessionResolution: string;
|
||||
queueAddedNowPlaying: string;
|
||||
queueAddedLater: string;
|
||||
playlistAddedNowPlaying: string;
|
||||
playlistAddedLater: string;
|
||||
queueTitle: string;
|
||||
queueEmpty: string;
|
||||
queueNowPlaying: string;
|
||||
queueUpcoming: string;
|
||||
queueMoreItems: string;
|
||||
queueRemoved: string;
|
||||
queueRemoveOutOfRange: string;
|
||||
queueRemoveOutOfRangeResolution: string;
|
||||
pauseSuccess: string;
|
||||
resumeSuccess: string;
|
||||
skipSuccess: string;
|
||||
leaveSuccess: string;
|
||||
stopSuccess: string;
|
||||
playbackStartedTitle: string;
|
||||
playbackIdleTitle: string;
|
||||
playbackIdleBody: string;
|
||||
playbackFailed: string;
|
||||
playbackFailedResolution: string;
|
||||
streamUnavailable: string;
|
||||
streamUnavailableResolution: string;
|
||||
requestedBy: string;
|
||||
duration: string;
|
||||
progress: string;
|
||||
source: string;
|
||||
status: string;
|
||||
queueLength: string;
|
||||
nextTrack: string;
|
||||
statusPlaying: string;
|
||||
statusPaused: string;
|
||||
unknownDuration: string;
|
||||
buttons: {
|
||||
pause: string;
|
||||
resume: string;
|
||||
skip: string;
|
||||
stop: string;
|
||||
leave: string;
|
||||
};
|
||||
};
|
||||
permissionAudit: {
|
||||
title: string;
|
||||
channel: string;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,26 @@
|
|||
import { extractYouTubeVideoId, formatDuration, isYouTubePlaylistUrl } from '../../src/services/MusicService';
|
||||
|
||||
describe('MusicService helpers', () => {
|
||||
it('extracts a video id from standard watch URLs', () => {
|
||||
expect(extractYouTubeVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
it('extracts a video id from short URLs', () => {
|
||||
expect(extractYouTubeVideoId('https://youtu.be/dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
it('returns null for invalid URLs', () => {
|
||||
expect(extractYouTubeVideoId('https://example.com/watch?v=dQw4w9WgXcQ')).toBeNull();
|
||||
});
|
||||
|
||||
it('detects playlist URLs', () => {
|
||||
expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist?list=PL1234567890')).toBe(true);
|
||||
expect(isYouTubePlaylistUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL1234567890')).toBe(true);
|
||||
expect(isYouTubePlaylistUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe(false);
|
||||
});
|
||||
|
||||
it('formats durations consistently', () => {
|
||||
expect(formatDuration(65)).toBe('01:05');
|
||||
expect(formatDuration(3665)).toBe('1:01:05');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue