Compare commits
No commits in common. "ad8b47d4ec8300398b16048430f933cdbf6aac5f" and "dcaa14091fb7cae43062081fa9eeea211cdc381c" have entirely different histories.
ad8b47d4ec
...
dcaa14091f
|
|
@ -1,27 +0,0 @@
|
||||||
---
|
|
||||||
description: Discord Bot UI/UX Design Philosophy
|
|
||||||
---
|
|
||||||
|
|
||||||
# Discord Bot UI/UX Design Philosophy
|
|
||||||
|
|
||||||
When designing or updating Discord command interfaces (Embeds, Components), adhere to the following UI/UX philosophy to ensure a clean, intuitive, and modern user experience.
|
|
||||||
|
|
||||||
## 1. Minimal and Non-redundant Information (중복 정보 최소화)
|
|
||||||
- Do not display information in the Embed that is already visually apparent in the UI components.
|
|
||||||
- For example, if a `RoleSelectMenuBuilder` allows the user to select roles, use `.addDefaultRoles(ids)` (available in discord.js 14.14+) to display the currently selected roles natively inside the dropdown menu.
|
|
||||||
- Do NOT list those same roles redundantly as text inside the Embed fields. The Embed should remain concise, showing only titles and essential descriptions or instructions.
|
|
||||||
|
|
||||||
## 2. Implicit State (명시적 토글 지양 및 상태 직관화)
|
|
||||||
- Avoid creating manual On/Off toggle buttons unless absolutely necessary.
|
|
||||||
- Derive the "Enabled/Disabled" state directly from the user's data naturally. For instance, if the user has selected at least one role (`roleIds.length > 0`), the feature is automatically considered "Active/Enabled". If they clear the selection, the feature is "Disabled".
|
|
||||||
- This reduces UI clutter (removing unnecessary toggle ActionRows) and aligns with modern design patterns where state implicitly follows the presence of data.
|
|
||||||
|
|
||||||
## 3. Persistent and Seamless Interaction (매끄러운 대시보드 유지)
|
|
||||||
- Component interactions should feel fast and seamless without fragmenting the chat history.
|
|
||||||
- Always immediately call `await interaction.deferUpdate();` (or equivalent) when handling components (buttons, select menus) to prevent "Unknown interaction" timeout errors.
|
|
||||||
- Use `await interaction.editReply(...)` with the newly generated UI components to seamlessly update the dashboard frame in place.
|
|
||||||
- Do NOT generate new follow-up messages or close the menu unilaterally when the user still expects to tweak settings.
|
|
||||||
|
|
||||||
## 4. Safe Response Timings (타임아웃 방지)
|
|
||||||
- When processing `ChatInputCommandInteraction` that might involve a database cold-start connection or external API calls, proactively call `await interaction.deferReply({ ephemeral: true });` right at the start.
|
|
||||||
- Update the UI with `await interaction.editReply(...)` once business logic resolves, bypassing Discord's strict 3-second timeout limitation and preventing crashes during initial boot load.
|
|
||||||
|
|
@ -12,4 +12,3 @@ LOG_LEVEL=info
|
||||||
# Log directory (kord.log + dated rotations). Relative = from process cwd; use an absolute path on servers
|
# Log directory (kord.log + dated rotations). Relative = from process cwd; use an absolute path on servers
|
||||||
# if the deploy directory is wiped (e.g. Jenkins): LOG_DIR=/var/lib/kord/logs
|
# if the deploy directory is wiped (e.g. Jenkins): LOG_DIR=/var/lib/kord/logs
|
||||||
LOG_DIR=logs
|
LOG_DIR=logs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json yarn.lock .yarnrc.yml ./
|
COPY package.json yarn.lock .yarnrc.yml ./
|
||||||
COPY .yarn ./.yarn
|
COPY .yarn ./.yarn
|
||||||
RUN corepack enable && yarn install --immutable
|
RUN corepack enable && yarn install
|
||||||
|
|
||||||
# Generate Prisma Client
|
# Generate Prisma Client
|
||||||
COPY prisma ./prisma/
|
COPY prisma ./prisma/
|
||||||
|
|
@ -17,8 +17,9 @@ FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json yarn.lock .yarnrc.yml ./
|
COPY package.json yarn.lock .yarnrc.yml ./
|
||||||
COPY .yarn ./.yarn
|
COPY .yarn ./.yarn
|
||||||
RUN corepack enable && yarn install --immutable --production
|
RUN corepack enable && yarn install
|
||||||
|
|
||||||
|
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
CMD ["node", "dist/index.js"]
|
CMD ["yarn", "node", "dist/index.js"]
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
# 프로젝트 구조 (Project Structure)
|
|
||||||
|
|
||||||
본 문서는 Kord 프로젝트의 주요 디렉터리와 아키텍처 구조를 설명합니다.
|
|
||||||
|
|
||||||
## 디렉터리 안내
|
|
||||||
|
|
||||||
### `src/` - 주요 소스 코드
|
|
||||||
|
|
||||||
본 봇의 모든 핵심 비즈니스 로직과 시스템 코드가 위치합니다.
|
|
||||||
|
|
||||||
- **`client/`**: `KordClient` 등 디스코드 봇 클라이언트 초기화 및 상태를 관리합니다.
|
|
||||||
- **`commands/`**: 디스코드 슬래시 명령어의 로직이 위치합니다.
|
|
||||||
- **`core/`**: 어플리케이션의 핵심 인프라 구성을 담당합니다. (`db.ts`, `command.ts` 기반 베이스 클래스 등)
|
|
||||||
- **`config/`**: 봇 구동 환경 및 전역 설정 관련 파일입니다.
|
|
||||||
- **`events/`** & **`handlers/`**: 디스코드 이벤트(messageCreate, interactionCreate 등)의 처리 및 바인딩을 담당합니다.
|
|
||||||
- **`i18n/`**: 다국어(한국어, 영어 등) 지원을 위한 로케일 데이터 및 번역 함수를 관리합니다.
|
|
||||||
- **`interactions/`**: 버튼, 셀렉트 메뉴, 모달 등 컴포넌트 상호작용 로직입니다.
|
|
||||||
- **`service/`** / **`services/`**: 각 도메인 별 비즈니스 로직 및 Prisma DB 읽기/쓰기 작업을 추상화한 서비스 계층입니다. (e.g. `FishingService`, `RefinementService`)
|
|
||||||
- **`utils/`**: 공통으로 사용되는 유틸리티 및 헬퍼 함수들입니다.
|
|
||||||
|
|
||||||
### `resource/` - 에셋 및 데이터 리소스
|
|
||||||
|
|
||||||
- 미니게임 아트웍(낚시 물고기 이미지, 무기 이미지 등) 및 정적 JSON 데이터(카탈로그, 확률표 등)를 보관합니다.
|
|
||||||
|
|
||||||
### `prisma/` - 데이터베이스 관리
|
|
||||||
|
|
||||||
- **`schema.prisma`**: PostgreSQL 데이터베이스의 스키마 명세입니다.
|
|
||||||
- **`migrations/`**: Prisma Migrate에 의해 자동 생성된 마이그레이션 이력입니다.
|
|
||||||
- **`seed.ts`**: 초기 데이터베이스 시딩을 위한 스크립트입니다.
|
|
||||||
|
|
||||||
### `Docs/` - 공식 문서
|
|
||||||
|
|
||||||
기능 명세, 트러블슈팅, 작업 내역, 데이터베이스 스키마 및 규칙 등 관리용 문서를 포함합니다. [`Docs/index.md`](index.md)를 중심으로 카테고리가 분류되어 있습니다.
|
|
||||||
|
|
||||||
### `tests/` - 테스트 코드
|
|
||||||
|
|
||||||
Jest 프레임워크를 기반으로 한 단위 테스트(Unit Test) 로직이 위치합니다. 주 도메인이나 `core`, `services` 코드에 대한 검증을 수행합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 향후 모놀리식 분리 (Modular Architecture) 진행 사항
|
|
||||||
|
|
||||||
최근 프로젝트 구조는 단일 스키마 구조에서 도메인/모듈 별로 패키지를 분리하기 위한 기초 작업이 진행되었습니다.
|
|
||||||
루트 디렉터리에 위치한 `schema_base.prisma`, `schema_feature.prisma`, `schema_main.prisma` 및 `package_feature.json`, `package_main.json` 파일들은 향후 핵심(Core/Base) 로직과 기능(Feature) 모듈을 독립적으로 빌드/관리하기 위해 도입될 예정(Work-in-Progress)입니다.
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
# 데이터베이스 테이블 구조
|
|
||||||
|
|
||||||
이 문서는 [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)를 함께 맞추는 것이 좋습니다.
|
|
||||||
|
|
@ -2,11 +2,6 @@
|
||||||
|
|
||||||
이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다.
|
이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다.
|
||||||
|
|
||||||
## 아키텍처 및 시스템 (Architecture & System)
|
|
||||||
|
|
||||||
- [프로젝트 구조 (Project Structure)](Project_Structure.md)
|
|
||||||
- [데이터베이스 스키마 구조 (Database Schema)](database-schema.md)
|
|
||||||
|
|
||||||
## 정책 및 규칙 (Rules)
|
## 정책 및 규칙 (Rules)
|
||||||
|
|
||||||
- [보안 가이드라인 (Security Rules)](Rules/security_guidelines.md)
|
- [보안 가이드라인 (Security Rules)](Rules/security_guidelines.md)
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,6 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
|
||||||
2. **실행**: `yarn start`
|
2. **실행**: `yarn start`
|
||||||
3. **Docker**: `docker-compose up -d`를 통해 PostgreSQL 등 로컬 인프라를 실행할 수 있습니다.
|
3. **Docker**: `docker-compose up -d`를 통해 PostgreSQL 등 로컬 인프라를 실행할 수 있습니다.
|
||||||
|
|
||||||
|
|
||||||
## 5. 기능 목록 (Feature List)
|
## 5. 기능 목록 (Feature List)
|
||||||
|
|
||||||
- **임시 음성 채널 (Voice)**: 생성기(Generator) 채널을 통해 동적인 음성 채널 생성 및 관리 기능을 제공합니다.
|
- **임시 음성 채널 (Voice)**: 생성기(Generator) 채널을 통해 동적인 음성 채널 생성 및 관리 기능을 제공합니다.
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,6 @@
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
kord:
|
|
||||||
build: .
|
|
||||||
container_name: kord-bot
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: kord-postgres
|
container_name: kord-postgres
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,8 +5,7 @@
|
||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@discordjs/voice": "^0.19.2",
|
"@discordjs/voice": "^0.19.2",
|
||||||
"@prisma/adapter-pg": "^7.6.0",
|
"@prisma/adapter-pg": "^7.6.0",
|
||||||
"@prisma/client": "^7.6.0",
|
"@prisma/client": "7.6.0",
|
||||||
"@prisma/config": "^7.6.0",
|
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
|
|
@ -25,7 +24,7 @@
|
||||||
"eslint": "^10.1.0",
|
"eslint": "^10.1.0",
|
||||||
"jest": "^30.3.0",
|
"jest": "^30.3.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prisma": "^7.6.0",
|
"prisma": "7.6.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "^29.4.6",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2"
|
"typescript": "^6.0.2"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "guild_payment" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"music" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"minigame" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"broadcast" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
CONSTRAINT "guild_payment_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "ExcludeType" AS ENUM ('ROLE', 'USER');
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "AutoRoleConfig" (
|
|
||||||
"guildId" TEXT NOT NULL,
|
|
||||||
"userRoleId" TEXT,
|
|
||||||
"botRoleId" TEXT,
|
|
||||||
"isEnabled" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"botEnabled" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "AutoRoleConfig_pkey" PRIMARY KEY ("guildId")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "AutoRoleExclude" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"guildId" TEXT NOT NULL,
|
|
||||||
"targetId" TEXT NOT NULL,
|
|
||||||
"type" "ExcludeType" NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "AutoRoleExclude_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "AutoRoleExclude_guildId_targetId_type_key" ON "AutoRoleExclude"("guildId", "targetId", "type");
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `botRoleId` on the `AutoRoleConfig` table. All the data in the column will be lost.
|
|
||||||
- You are about to drop the column `userRoleId` on the `AutoRoleConfig` table. All the data in the column will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "AutoRoleConfig" DROP COLUMN "botRoleId",
|
|
||||||
DROP COLUMN "userRoleId",
|
|
||||||
ADD COLUMN "botRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
|
||||||
ADD COLUMN "userRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the `AutoRoleExclude` table. If the table is not empty, all the data it contains will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "AutoRoleExclude";
|
|
||||||
|
|
||||||
-- DropEnum
|
|
||||||
DROP TYPE "ExcludeType";
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the `InviteRole` table. If the table is not empty, all the data it contains will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "InviteRole";
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
previewFeatures = ["driverAdapters"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
|
|
@ -17,6 +16,16 @@ model GuildConfig {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model InviteRole {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
guildId String
|
||||||
|
inviteCode String
|
||||||
|
roleId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([guildId, inviteCode])
|
||||||
|
}
|
||||||
|
|
||||||
model UserSubscription {
|
model UserSubscription {
|
||||||
userId String @id
|
userId String @id
|
||||||
tier SubscriptionTier @default(FREE)
|
tier SubscriptionTier @default(FREE)
|
||||||
|
|
@ -131,67 +140,47 @@ enum EventStatus {
|
||||||
COMPLETED
|
COMPLETED
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 길드별 유료 기능(특성) 플래그. 행이 없거나 해당 컬럼이 false면 미결제로 간주합니다.
|
// ─── Mini Game System ───────────────────────────────────────────────────────
|
||||||
model GuildPayment {
|
|
||||||
id String @id
|
|
||||||
music Boolean @default(false)
|
|
||||||
minigame Boolean @default(false)
|
|
||||||
broadcast Boolean @default(false)
|
|
||||||
|
|
||||||
@@map("guild_payment")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 서버별 미니게임 활성화 상태 관리
|
||||||
model MiniGameConfig {
|
model MiniGameConfig {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
guildId String
|
guildId String
|
||||||
gameKey String
|
gameKey String
|
||||||
enabled Boolean @default(false)
|
enabled Boolean @default(false)
|
||||||
channelId String?
|
channelId String?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([guildId])
|
|
||||||
@@unique([guildId, gameKey])
|
@@unique([guildId, gameKey])
|
||||||
|
@@index([guildId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 재련 - 유저 상태
|
||||||
model RefinementProfile {
|
model RefinementProfile {
|
||||||
userId String
|
userId String
|
||||||
guildId String
|
guildId String
|
||||||
gold Int @default(1000)
|
gold Int @default(1000)
|
||||||
weaponLevel Int @default(0)
|
weaponLevel Int @default(0)
|
||||||
maxWeaponLevel Int @default(0)
|
maxWeaponLevel Int @default(0)
|
||||||
durability Int @default(10)
|
durability Int @default(10)
|
||||||
tryCount Int @default(0)
|
tryCount Int @default(0)
|
||||||
successCount Int @default(0)
|
successCount Int @default(0)
|
||||||
failCount Int @default(0)
|
failCount Int @default(0)
|
||||||
destroyCount Int @default(0)
|
destroyCount Int @default(0)
|
||||||
battleWin Int @default(0)
|
battleWin Int @default(0)
|
||||||
battleLoss Int @default(0)
|
battleLoss Int @default(0)
|
||||||
dailyBattleCount Int @default(0)
|
dailyBattleCount Int @default(0)
|
||||||
isDisabled Boolean @default(false)
|
lastBattleReset DateTime @default(now())
|
||||||
lastCheckIn DateTime?
|
isDisabled Boolean @default(false)
|
||||||
lastBattleReset DateTime @default(now())
|
lastCheckIn DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@id([userId, guildId])
|
@@id([userId, guildId])
|
||||||
@@index([guildId, weaponLevel(sort: Desc)])
|
@@index([guildId, weaponLevel(sort: Desc)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 재련 - 레벨별 수치 설정 (수치 대조 및 수정용)
|
||||||
|
|
||||||
|
|
||||||
model AutoRoleConfig {
|
|
||||||
guildId String @id
|
|
||||||
userRoleIds String[] @default([])
|
|
||||||
botRoleIds String[] @default([])
|
|
||||||
isEnabled Boolean @default(false)
|
|
||||||
botEnabled Boolean @default(false)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
model RefinementLevelConfig {
|
model RefinementLevelConfig {
|
||||||
level Int @id
|
level Int @id
|
||||||
successRate Float
|
successRate Float
|
||||||
|
|
@ -202,13 +191,15 @@ model RefinementLevelConfig {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 재련 - 레벨 차이별 승률 설정 (승률 곡선 관리)
|
||||||
model RefinementBattleConfig {
|
model RefinementBattleConfig {
|
||||||
levelGap Int @id
|
levelGap Int @id // (공격자 레벨 - 방어자 레벨)
|
||||||
winRate Float
|
winRate Float
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 재련 - 전역 시스템 설정 (일일 제한, 초기 자본 등)
|
||||||
model RefinementSystemConfig {
|
model RefinementSystemConfig {
|
||||||
key String @id
|
key String @id
|
||||||
value String
|
value String
|
||||||
|
|
@ -275,4 +266,3 @@ model FishingCollectionEntry {
|
||||||
@@id([userId, guildId, fishId])
|
@@id([userId, guildId, fishId])
|
||||||
@@index([guildId, userId])
|
@@index([guildId, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { handleGlobalExceptions } from '../utils/errorHandler';
|
||||||
import { connectDB } from '../database';
|
import { connectDB } from '../database';
|
||||||
import { FeverService } from '../services/FeverService';
|
import { FeverService } from '../services/FeverService';
|
||||||
|
|
||||||
|
|
||||||
export class KordClient extends Client {
|
export class KordClient extends Client {
|
||||||
public commands: Collection<string, any> = new Collection();
|
public commands: Collection<string, any> = new Collection();
|
||||||
|
|
||||||
|
|
@ -18,10 +17,8 @@ export class KordClient extends Client {
|
||||||
GatewayIntentBits.GuildVoiceStates,
|
GatewayIntentBits.GuildVoiceStates,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
GatewayIntentBits.GuildMembers,
|
|
||||||
GatewayIntentBits.GuildInvites,
|
GatewayIntentBits.GuildInvites,
|
||||||
],
|
],
|
||||||
|
|
||||||
partials: [Partials.Message, Partials.Channel, Partials.GuildMember],
|
partials: [Partials.Message, Partials.Channel, Partials.GuildMember],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import {
|
|
||||||
SlashCommandBuilder,
|
|
||||||
ChatInputCommandInteraction,
|
|
||||||
PermissionFlagsBits,
|
|
||||||
EmbedBuilder,
|
|
||||||
Colors,
|
|
||||||
ActionRowBuilder,
|
|
||||||
ButtonBuilder,
|
|
||||||
ButtonStyle,
|
|
||||||
RoleSelectMenuBuilder,
|
|
||||||
ComponentType
|
|
||||||
} from 'discord.js';
|
|
||||||
import { Command, CommandTrait } from '../core/command';
|
|
||||||
import { autoRoleService } from '../services/AutoRoleService';
|
|
||||||
import { t, SupportedLocale } from '../i18n';
|
|
||||||
|
|
||||||
class AutoRoleCommand extends Command {
|
|
||||||
protected override readonly trait = CommandTrait.General;
|
|
||||||
protected override guildOnly = true;
|
|
||||||
|
|
||||||
protected override define() {
|
|
||||||
return new SlashCommandBuilder()
|
|
||||||
.setName('autorole')
|
|
||||||
.setDescription('Configure automatic role assignment upon joining.')
|
|
||||||
.setDescriptionLocalizations({
|
|
||||||
ko: '입장 시 역할을 자동으로 부여하는 기능을 설정합니다.',
|
|
||||||
})
|
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async handle(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
|
|
||||||
const guild = interaction.guild!;
|
|
||||||
const dashboard = await generateAutoRoleDashboard(guild, locale);
|
|
||||||
await interaction.editReply({
|
|
||||||
...dashboard
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateAutoRoleDashboard(guild: import('discord.js').Guild, locale: SupportedLocale) {
|
|
||||||
const config = await autoRoleService.getConfig(guild.id);
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle(t(locale, 'commands.autorole.statusTitle'))
|
|
||||||
.setColor(Colors.Blue)
|
|
||||||
.setDescription(t(locale, 'commands.autorole.description') || '유저 및 봇이 서버에 접속할 때 자동으로 부여할 기본 역할을 선택하세요. 역할을 선택하면 즉시 활성화됩니다.');
|
|
||||||
|
|
||||||
const userSelect = new RoleSelectMenuBuilder()
|
|
||||||
.setCustomId('autorole_select_user')
|
|
||||||
.setPlaceholder(t(locale, 'commands.autorole.userRolePlaceholder'))
|
|
||||||
.setMaxValues(10);
|
|
||||||
if (config?.userRoleIds && config.userRoleIds.length > 0) {
|
|
||||||
userSelect.addDefaultRoles(config.userRoleIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowUserRole = new ActionRowBuilder<RoleSelectMenuBuilder>().addComponents(userSelect);
|
|
||||||
|
|
||||||
const botSelect = new RoleSelectMenuBuilder()
|
|
||||||
.setCustomId('autorole_select_bot')
|
|
||||||
.setPlaceholder(t(locale, 'commands.autorole.botRolePlaceholder'))
|
|
||||||
.setMaxValues(10);
|
|
||||||
if (config?.botRoleIds && config.botRoleIds.length > 0) {
|
|
||||||
botSelect.addDefaultRoles(config.botRoleIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowBotRole = new ActionRowBuilder<RoleSelectMenuBuilder>().addComponents(botSelect);
|
|
||||||
|
|
||||||
return {
|
|
||||||
embeds: [embed],
|
|
||||||
components: [rowUserRole, rowBotRole]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new AutoRoleCommand().toModule();
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
import {
|
|
||||||
SlashCommandBuilder,
|
|
||||||
SlashCommandOptionsOnlyBuilder,
|
|
||||||
SlashCommandSubcommandsOnlyBuilder,
|
|
||||||
ChatInputCommandInteraction,
|
|
||||||
} from 'discord.js';
|
|
||||||
|
|
||||||
/** Values returned from {@link SlashCommandBuilder} after chaining options or subcommands only. */
|
|
||||||
export type SlashCommandData =
|
|
||||||
| SlashCommandBuilder
|
|
||||||
| SlashCommandOptionsOnlyBuilder
|
|
||||||
| SlashCommandSubcommandsOnlyBuilder;
|
|
||||||
import { prisma } from '../database';
|
|
||||||
import { SupportedLocale } from '../i18n';
|
|
||||||
import { SubscriptionTier } from '@prisma/client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 명령의 도메인·특성 구분입니다.
|
|
||||||
*
|
|
||||||
* - 같은 특성끼리 묶어 help 표시, 권한 정책, 기능 플래그, 통계 등에 활용할 수 있습니다.
|
|
||||||
* - `Command`를 쓰지 않는 기존 명령 모듈에는 값이 없을 수 있습니다.
|
|
||||||
*/
|
|
||||||
export enum CommandTrait {
|
|
||||||
/** 음악 재생·대기열 등 */
|
|
||||||
Music = 'music',
|
|
||||||
/** 미니게임 */
|
|
||||||
Minigame = 'minigame',
|
|
||||||
/** 방송 연동·알림 등 */
|
|
||||||
Broadcast = 'broadcast',
|
|
||||||
/** 위에 해당하지 않는 일반 명령(설정·감사·음성 등) */
|
|
||||||
General = 'general',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** {@link CommandTrait.General}을 제외한 특성은 `guild_payment` 플래그가 필요합니다. */
|
|
||||||
export function traitRequiresPayment(trait: CommandTrait): boolean {
|
|
||||||
return (
|
|
||||||
trait === CommandTrait.Music || trait === CommandTrait.Minigame || trait === CommandTrait.Broadcast
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 유료 특성 명령에 대해 길드 결제 플래그를 확인합니다.
|
|
||||||
*
|
|
||||||
* @returns 통과 시 `true`. 이미 `reply`로 응답한 경우 `false`.
|
|
||||||
*/
|
|
||||||
export async function ensureGuildPaidForTrait(
|
|
||||||
interaction: ChatInputCommandInteraction,
|
|
||||||
trait: CommandTrait,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!traitRequiresPayment(trait)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const guildId = interaction.guildId;
|
|
||||||
if (!guildId) {
|
|
||||||
const content = '이 명령은 서버에서만 사용할 수 있습니다.';
|
|
||||||
if (interaction.deferred || interaction.replied) {
|
|
||||||
await interaction.editReply({ content });
|
|
||||||
} else {
|
|
||||||
await interaction.reply({ content, ephemeral: true });
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Payment flags were replaced by subscription tiers.
|
|
||||||
// A guild is considered "paid" if it has an owner whose subscription tier is above FREE.
|
|
||||||
const ownership = await prisma.guildOwnership.findUnique({
|
|
||||||
where: { guildId },
|
|
||||||
include: { owner: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const paid = ownership?.owner?.tier != null && ownership.owner.tier !== SubscriptionTier.FREE;
|
|
||||||
|
|
||||||
if (!paid) {
|
|
||||||
const content = '결제가 되지않았습니다';
|
|
||||||
if (interaction.deferred || interaction.replied) {
|
|
||||||
await interaction.editReply({ content });
|
|
||||||
} else {
|
|
||||||
await interaction.reply({ content, ephemeral: true });
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 슬래시 명령을 `CommandLoader`에 넘길 때 쓰는 모듈 형태입니다.
|
|
||||||
*
|
|
||||||
* `src/handlers/CommandLoader.ts`는 각 명령 파일의 `default` export가
|
|
||||||
* `data`(슬래시 정의)와 `execute`(실행 함수) 속성을 가지는지 검사한 뒤,
|
|
||||||
* `client.commands.set(command.data.name, command)`로 등록합니다.
|
|
||||||
* 따라서 이 타입은 디스코드에 올리는 빌더와, 실제 처리 진입점을 한 묶음으로 맞춥니다.
|
|
||||||
*
|
|
||||||
* `trait`은 {@link Command.toModule}을 통해 붙이며, 특성 필터링 시 사용합니다.
|
|
||||||
*/
|
|
||||||
export type CommandModule = {
|
|
||||||
data: SlashCommandData;
|
|
||||||
execute: (interaction: ChatInputCommandInteraction, locale: SupportedLocale) => Promise<void>;
|
|
||||||
trait?: CommandTrait;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 슬래시 명령용 추상 베이스 클래스입니다.
|
|
||||||
*
|
|
||||||
* **사용 흐름**
|
|
||||||
* 1. 이 클래스를 상속한 뒤 {@link trait}에 {@link CommandTrait} 중 하나를 지정합니다.
|
|
||||||
* 2. {@link define}에서 `SlashCommandBuilder`로 이름·설명·옵션·권한 등을 구성합니다.
|
|
||||||
* 3. {@link handle}에서 실제 비즈니스 로직(답장·DB·서비스 호출)을 작성합니다.
|
|
||||||
* 4. `export default new MyCommand().toModule()`처럼 `toModule()` 결과를 기본 내보내기 하면
|
|
||||||
* 기존 `commands/*.ts`와 동일하게 로더에 잡힙니다(`trait` 메타데이터 포함).
|
|
||||||
*
|
|
||||||
* **실행 순서** (`interactionCreate` → 본 클래스의 `execute`)
|
|
||||||
* 1. `guildOnly === true`이면 DM 등 길드가 아닌 경우 즉시 안내 메시지 후 종료합니다.
|
|
||||||
* 2. {@link CommandTrait.Music} / {@link CommandTrait.Minigame} / {@link CommandTrait.Broadcast}인 경우
|
|
||||||
* `guild_payment` 테이블을 조회해 해당 플래그가 `true`일 때만 진행합니다. (행 없음·`false` → 「결제가 되지않았습니다」)
|
|
||||||
* 3. {@link beforeHandle}이 `false`를 반환하면(이미 응답을 보낸 경우 등) {@link handle}은 호출되지 않습니다.
|
|
||||||
* 4. 그렇지 않으면 {@link handle}을 실행합니다.
|
|
||||||
*
|
|
||||||
* `events/interactionCreate.ts`는 로케일을 resolve한 뒤 `command.execute(interaction, locale)`만 호출하므로,
|
|
||||||
* 명령 파일마다 반복되던 길드-only 같은 공통 처리는 이 클래스에 모을 수 있습니다.
|
|
||||||
*/
|
|
||||||
export abstract class Command {
|
|
||||||
private cachedData: SlashCommandData | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이 명령의 도메인 특성입니다. 서브클래스에서 반드시 한 값으로 고정하세요.
|
|
||||||
*
|
|
||||||
* 예: 음악 명령 → {@link CommandTrait.Music}, 미니게임 → {@link CommandTrait.Minigame},
|
|
||||||
* 방송 → {@link CommandTrait.Broadcast}, 그 외 → {@link CommandTrait.General}.
|
|
||||||
*/
|
|
||||||
protected abstract readonly trait: CommandTrait;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `true`이면 **서버(길드) 안에서만** 명령이 동작합니다.
|
|
||||||
*
|
|
||||||
* DM이나 길드 컨텍스트가 없는 상호작용에서는 {@link handle}에 들어가기 전에
|
|
||||||
* 영문 안내를 다른 사용자에게는 보이지 않는(ephemeral) 형태로 보내고 return 합니다.
|
|
||||||
* 서버 전용 명령(채널·역할·길드 설정 등)에 켜 두면 됩니다.
|
|
||||||
*/
|
|
||||||
protected guildOnly = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 디스코드에 등록할 슬래시 명령 빌더입니다.
|
|
||||||
*
|
|
||||||
* 최초 접근 시 {@link define}을 한 번만 호출해 결과를 캐시합니다.
|
|
||||||
* 같은 인스턴스에서 `data`를 여러 번 읽어도 빌더는 한 번만 만들어집니다.
|
|
||||||
*/
|
|
||||||
get data(): SlashCommandData {
|
|
||||||
if (!this.cachedData) {
|
|
||||||
this.cachedData = this.define();
|
|
||||||
}
|
|
||||||
return this.cachedData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 슬래시 명령의 정의(이름, 설명, 로컬라이즈, 서브커맨드, 옵션, `setDefaultMemberPermissions` 등)를
|
|
||||||
* `SlashCommandBuilder`로 구성해 반환합니다.
|
|
||||||
*/
|
|
||||||
protected abstract define(): SlashCommandData;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 공통 가드를 통과한 뒤 실행되는 본 처리입니다.
|
|
||||||
*
|
|
||||||
* `interaction`은 채팅 입력 슬래시이고, `locale`은 사용자/길드 설정을 반영해 resolve된 값입니다.
|
|
||||||
*/
|
|
||||||
protected abstract handle(
|
|
||||||
interaction: ChatInputCommandInteraction,
|
|
||||||
locale: SupportedLocale,
|
|
||||||
): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@link handle} 직전에 한 번 호출되는 선택적 훅입니다.
|
|
||||||
*
|
|
||||||
* 권한 검사, rate limit, 잘못된 옵션 조합에 대한 조기 응답 등 **여러 명령에서 공통으로 쓸 선행 로직**을
|
|
||||||
* 넣기 좋습니다.
|
|
||||||
*
|
|
||||||
* @returns `true`이면 그대로 {@link handle}으로 진행합니다.
|
|
||||||
* `false`이면 **이미 `reply`/`deferReply` 등으로 응답을 끝낸 것으로 간주**하고
|
|
||||||
* `handle`은 호출하지 않습니다.
|
|
||||||
*/
|
|
||||||
protected async beforeHandle(
|
|
||||||
_interaction: ChatInputCommandInteraction,
|
|
||||||
_locale: SupportedLocale,
|
|
||||||
): Promise<boolean> {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로더/디스코드가 호출하는 진입점입니다.
|
|
||||||
*
|
|
||||||
* 길드-only 검사 → 유료 특성 시 {@link ensureGuildPaidForTrait} → `beforeHandle` → `handle` 순으로 위임합니다.
|
|
||||||
* 일반적으로 서브클래스에서 이 메서드를 직접 오버라이드할 필요는 없습니다.
|
|
||||||
*/
|
|
||||||
async execute(
|
|
||||||
interaction: ChatInputCommandInteraction,
|
|
||||||
locale: SupportedLocale,
|
|
||||||
): Promise<void> {
|
|
||||||
if (this.guildOnly && !interaction.inGuild()) {
|
|
||||||
const content = 'This command can only be used in a server.';
|
|
||||||
if (interaction.deferred || interaction.replied) {
|
|
||||||
await interaction.editReply({ content });
|
|
||||||
} else {
|
|
||||||
await interaction.reply({ content, ephemeral: true });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await ensureGuildPaidForTrait(interaction, this.trait))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await this.beforeHandle(interaction, locale))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.handle(interaction, locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `CommandModule` 형태로 묶어 `default export`에 넘깁니다.
|
|
||||||
*
|
|
||||||
* `execute`는 이 인스턴스에 바인딩되므로, 서브클래스에서 `this`를 쓰는 메서드도 그대로 동작합니다.
|
|
||||||
*/
|
|
||||||
toModule(): CommandModule {
|
|
||||||
return {
|
|
||||||
data: this.data,
|
|
||||||
execute: (interaction, locale) => this.execute(interaction, locale),
|
|
||||||
trait: this.trait,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { Prisma, PrismaClient } from '@prisma/client';
|
|
||||||
import { prisma } from '../database';
|
|
||||||
|
|
||||||
export type DbClient = PrismaClient;
|
|
||||||
export type TxClient = Prisma.TransactionClient;
|
|
||||||
|
|
||||||
function isRootClient(client: DbClient | TxClient): client is DbClient {
|
|
||||||
return typeof (client as DbClient).$transaction === 'function';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs `fn` inside a DB transaction.
|
|
||||||
*
|
|
||||||
* - If `fn` throws/rejects, **all operations are rolled back**.
|
|
||||||
* - Prefer this over array-based transactions when you need multiple steps
|
|
||||||
* (reads + conditional writes) to be atomic.
|
|
||||||
*/
|
|
||||||
export async function transaction<T>(
|
|
||||||
fn: (tx: TxClient) => Promise<T>,
|
|
||||||
options?: {
|
|
||||||
maxWait?: number;
|
|
||||||
timeout?: number;
|
|
||||||
isolationLevel?: Prisma.TransactionIsolationLevel;
|
|
||||||
},
|
|
||||||
): Promise<T> {
|
|
||||||
return prisma.$transaction(fn, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility to support both "already in a transaction" and "start a new one".
|
|
||||||
*
|
|
||||||
* If `client` is a root PrismaClient, it starts a transaction.
|
|
||||||
* If `client` is already a TransactionClient, it reuses it.
|
|
||||||
*/
|
|
||||||
export async function withTransaction<T>(
|
|
||||||
client: DbClient | TxClient,
|
|
||||||
fn: (tx: TxClient) => Promise<T>,
|
|
||||||
options?: {
|
|
||||||
maxWait?: number;
|
|
||||||
timeout?: number;
|
|
||||||
isolationLevel?: Prisma.TransactionIsolationLevel;
|
|
||||||
},
|
|
||||||
): Promise<T> {
|
|
||||||
if (isRootClient(client)) {
|
|
||||||
return client.$transaction(fn, options);
|
|
||||||
}
|
|
||||||
return fn(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { Events, Guild } from 'discord.js';
|
import { Events, Guild } from 'discord.js';
|
||||||
|
import { InviteService } from '../services/InviteService';
|
||||||
import { PresenceService } from '../services/PresenceService';
|
import { PresenceService } from '../services/PresenceService';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.GuildCreate,
|
name: Events.GuildCreate,
|
||||||
once: false,
|
once: false,
|
||||||
async execute(guild: Guild) {
|
async execute(guild: Guild) {
|
||||||
|
await InviteService.cacheGuildInvites(guild);
|
||||||
PresenceService.updatePresence(guild.client);
|
PresenceService.updatePresence(guild.client);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { Events, GuildMember } from 'discord.js';
|
import { Events, GuildMember } from 'discord.js';
|
||||||
import { autoRoleService } from '../services/AutoRoleService';
|
import { InviteService } from '../services/InviteService';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.GuildMemberAdd,
|
name: Events.GuildMemberAdd,
|
||||||
once: false,
|
once: false,
|
||||||
async execute(member: GuildMember) {
|
async execute(member: GuildMember) {
|
||||||
await autoRoleService.handleMemberJoin(member);
|
await InviteService.handleMemberAdd(member);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -145,40 +145,6 @@ export default {
|
||||||
}, locale);
|
}, locale);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (interaction.isButton() && interaction.customId.startsWith('autorole_')) {
|
|
||||||
const locale = await getInteractionLocale(interaction);
|
|
||||||
const { autoRoleService } = require('../services/AutoRoleService');
|
|
||||||
await withErrorHandler(interaction, async () => {
|
|
||||||
// 타임아웃 방지를 위해 즉시 승인
|
|
||||||
await interaction.deferUpdate();
|
|
||||||
|
|
||||||
// 나머지 버튼 처리 (현재 사용 안함)
|
|
||||||
}, locale);
|
|
||||||
}
|
|
||||||
else if (interaction.isRoleSelectMenu() && interaction.customId.startsWith('autorole_select_')) {
|
|
||||||
const locale = await getInteractionLocale(interaction);
|
|
||||||
const { autoRoleService } = require('../services/AutoRoleService');
|
|
||||||
await withErrorHandler(interaction, async () => {
|
|
||||||
// 타임아웃 방지를 위해 즉시 승인
|
|
||||||
await interaction.deferUpdate();
|
|
||||||
|
|
||||||
const guild = interaction.guild!;
|
|
||||||
const isBot = interaction.customId.includes('bot');
|
|
||||||
const roleIds = interaction.values;
|
|
||||||
|
|
||||||
await autoRoleService.updateConfig(guild.id, {
|
|
||||||
[isBot ? 'botRoleIds' : 'userRoleIds']: roleIds
|
|
||||||
});
|
|
||||||
|
|
||||||
const { generateAutoRoleDashboard } = require('../commands/autorole');
|
|
||||||
const dashboard = await generateAutoRoleDashboard(guild, locale);
|
|
||||||
|
|
||||||
await interaction.editReply({
|
|
||||||
content: '',
|
|
||||||
...dashboard
|
|
||||||
});
|
|
||||||
}, locale);
|
|
||||||
}
|
|
||||||
else if (interaction.isModalSubmit()) {
|
else if (interaction.isModalSubmit()) {
|
||||||
const customId = interaction.customId;
|
const customId = interaction.customId;
|
||||||
if (customId.startsWith('modal_vc_')) {
|
if (customId.startsWith('modal_vc_')) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Events, Invite } from 'discord.js';
|
||||||
|
import { InviteService } from '../services/InviteService';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: Events.InviteCreate,
|
||||||
|
once: false,
|
||||||
|
async execute(invite: Invite) {
|
||||||
|
await InviteService.handleInviteCreate(invite);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Events, Invite } from 'discord.js';
|
||||||
|
import { InviteService } from '../services/InviteService';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: Events.InviteDelete,
|
||||||
|
once: false,
|
||||||
|
async execute(invite: Invite) {
|
||||||
|
await InviteService.handleInviteDelete(invite);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { Events } from 'discord.js';
|
import { Events } from 'discord.js';
|
||||||
import { KordClient } from '../client/KordClient';
|
import { KordClient } from '../client/KordClient';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import { InviteService } from '../services/InviteService';
|
||||||
import { VoiceService } from '../services/VoiceService';
|
import { VoiceService } from '../services/VoiceService';
|
||||||
import { PresenceService } from '../services/PresenceService';
|
import { PresenceService } from '../services/PresenceService';
|
||||||
import { EventService } from '../services/EventService';
|
import { EventService } from '../services/EventService';
|
||||||
import { auditLogService } from '../services/AuditLogService';
|
import { auditLogService } from '../services/AuditLogService';
|
||||||
|
|
||||||
|
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -14,6 +13,7 @@ export default {
|
||||||
once: true,
|
once: true,
|
||||||
async execute(client: KordClient) {
|
async execute(client: KordClient) {
|
||||||
logger.info(`Ready! Logged in as ${client.user?.tag}`);
|
logger.info(`Ready! Logged in as ${client.user?.tag}`);
|
||||||
|
await InviteService.cacheAllInvites(client);
|
||||||
await VoiceService.syncChannels(client);
|
await VoiceService.syncChannels(client);
|
||||||
PresenceService.startActivePresence(client);
|
PresenceService.startActivePresence(client);
|
||||||
EventService.startReminderLoop(client);
|
EventService.startReminderLoop(client);
|
||||||
|
|
@ -23,7 +23,6 @@ export default {
|
||||||
await client.application?.commands.set(commandsData);
|
await client.application?.commands.set(commandsData);
|
||||||
logger.info(`Successfully registered ${commandsData.length} global application commands.`);
|
logger.info(`Successfully registered ${commandsData.length} global application commands.`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
||||||
logger.error('Failed to register global commands', e);
|
logger.error('Failed to register global commands', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -191,26 +191,6 @@ export const en: TranslationSchema = {
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
autorole: {
|
|
||||||
description: 'Configure automatic role assignment upon joining.',
|
|
||||||
statusTitle: 'Auto Role Configuration Status',
|
|
||||||
userRoleLabel: 'User Role',
|
|
||||||
botRoleLabel: 'Bot Role',
|
|
||||||
statusLabel: 'User Auto Assignment',
|
|
||||||
botStatusLabel: 'Bot Auto Assignment',
|
|
||||||
userRolePlaceholder: 'Select default user role',
|
|
||||||
botRolePlaceholder: 'Select default bot role',
|
|
||||||
toggleUserEnable: '🟢 Enable User AutoRole',
|
|
||||||
toggleUserDisable: '🔴 Disable User AutoRole',
|
|
||||||
toggleBotEnable: '🟢 Enable Bot AutoRole',
|
|
||||||
toggleBotDisable: '🔴 Disable Bot AutoRole',
|
|
||||||
notSet: 'Not Set',
|
|
||||||
enabled: 'Enabled',
|
|
||||||
disabled: 'Disabled',
|
|
||||||
updateSuccess: 'Auto role settings have been updated.',
|
|
||||||
permissionsError: 'Failed to assign role due to low bot hierarchy or missing permissions.',
|
|
||||||
suspendNotice: 'Auto role assignment has been suspended due to insufficient permissions. Please check the bot\'s permissions and role hierarchy.',
|
|
||||||
},
|
|
||||||
music: {
|
music: {
|
||||||
description: 'Play YouTube audio in voice channels.',
|
description: 'Play YouTube audio in voice channels.',
|
||||||
addDescription: 'Search YouTube or add a video URL to the queue.',
|
addDescription: 'Search YouTube or add a video URL to the queue.',
|
||||||
|
|
@ -348,6 +328,8 @@ export const en: TranslationSchema = {
|
||||||
VOICE_GLOBAL: 'Voice Channels (Global)',
|
VOICE_GLOBAL: 'Voice Channels (Global)',
|
||||||
VOICE_GENERATOR_CHANNEL: 'Voice Generator Channel',
|
VOICE_GENERATOR_CHANNEL: 'Voice Generator Channel',
|
||||||
VOICE_GENERATOR_CATEGORY: 'Voice Generator Category',
|
VOICE_GENERATOR_CATEGORY: 'Voice Generator Category',
|
||||||
|
INVITE_TRACKING: 'Invite Tracking',
|
||||||
|
INVITE_ROLE_HIERARCHY: 'Invite Role Assignment (Hierarchy)',
|
||||||
MIMIC_WEBHOOK: 'Message Mimic (Webhook)',
|
MIMIC_WEBHOOK: 'Message Mimic (Webhook)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -406,6 +388,7 @@ export const en: TranslationSchema = {
|
||||||
BOOT: 'Boot',
|
BOOT: 'Boot',
|
||||||
VOICE: 'Voice',
|
VOICE: 'Voice',
|
||||||
PERMISSION: 'Permission',
|
PERMISSION: 'Permission',
|
||||||
|
INVITE: 'Invite',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { TranslationSchema } from '../types';
|
||||||
* 한국어 번역. en.ts와 키 구조가 1:1로 대응해야 합니다.
|
* 한국어 번역. en.ts와 키 구조가 1:1로 대응해야 합니다.
|
||||||
*/
|
*/
|
||||||
export const ko: TranslationSchema = {
|
export const ko: TranslationSchema = {
|
||||||
// ── 에러 메시지 ─────────────────────────────────────────
|
|
||||||
errors: {
|
errors: {
|
||||||
E1001: {
|
E1001: {
|
||||||
userMessage: '사용자 제한 값이 올바르지 않습니다.',
|
userMessage: '사용자 제한 값이 올바르지 않습니다.',
|
||||||
|
|
@ -21,7 +20,6 @@ export const ko: TranslationSchema = {
|
||||||
userMessage: '선택한 사용자가 음성 채널에 없습니다.',
|
userMessage: '선택한 사용자가 음성 채널에 없습니다.',
|
||||||
resolution: '작업 전에 해당 사용자가 채널에 있는지 확인해 주세요.',
|
resolution: '작업 전에 해당 사용자가 채널에 있는지 확인해 주세요.',
|
||||||
},
|
},
|
||||||
|
|
||||||
E2001: {
|
E2001: {
|
||||||
userMessage: '봇에게 채널을 관리할 권한이 부족합니다.',
|
userMessage: '봇에게 채널을 관리할 권한이 부족합니다.',
|
||||||
resolution: '서버 관리자에게 봇에게 「채널 관리」 권한을 부여해 달라고 요청해 주세요.',
|
resolution: '서버 관리자에게 봇에게 「채널 관리」 권한을 부여해 달라고 요청해 주세요.',
|
||||||
|
|
@ -76,22 +74,19 @@ export const ko: TranslationSchema = {
|
||||||
errorTitles: {
|
errorTitles: {
|
||||||
USER_INPUT: '입력을 확인해주세요',
|
USER_INPUT: '입력을 확인해주세요',
|
||||||
PERMISSION: '권한이 부족합니다',
|
PERMISSION: '권한이 부족합니다',
|
||||||
BOT_INTERNAL: '내부 오류가 발생했습니다.',
|
BOT_INTERNAL: '문제가 발생했습니다',
|
||||||
DISCORD_API: '일시적인 문제입니다.',
|
DISCORD_API: '일시적인 문제입니다.',
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
errorFields: {
|
errorFields: {
|
||||||
resolution: '💡 해결 방법',
|
resolution: '💡 해결 방법',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 음성 채널 ───────────────────────────────────────────
|
|
||||||
|
|
||||||
voice: {
|
voice: {
|
||||||
channelReady: '{{owner}}, 임시 채널이 준비되었습니다! 아래 드롭다운 메뉴로 관리하세요.',
|
channelReady: '{{owner}}, 임시 채널이 준비되었습니다! 아래 드롭다운 메뉴로 관리하세요.',
|
||||||
defaultRoomName: '{{username}}의 방',
|
defaultRoomName: '{{username}}의 방',
|
||||||
controlPanel: {
|
controlPanel: {
|
||||||
placeholder: '⚙️ 채널 설정 관리',
|
placeholder: '채널 설정 관리',
|
||||||
rename: '채널 이름 변경',
|
rename: '채널 이름 변경',
|
||||||
limit: '인원 제한 설정',
|
limit: '인원 제한 설정',
|
||||||
lock: '채널 잠금 / 해제',
|
lock: '채널 잠금 / 해제',
|
||||||
|
|
@ -99,7 +94,6 @@ export const ko: TranslationSchema = {
|
||||||
ban: '유저 차단 / 숨기기',
|
ban: '유저 차단 / 숨기기',
|
||||||
transfer: '소유권 이전',
|
transfer: '소유권 이전',
|
||||||
},
|
},
|
||||||
|
|
||||||
responses: {
|
responses: {
|
||||||
channelLocked: '채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.',
|
channelLocked: '채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.',
|
||||||
channelUnlocked: '채널 잠금이 해제되었습니다! 누구나 참여할 수 있습니다.',
|
channelUnlocked: '채널 잠금이 해제되었습니다! 누구나 참여할 수 있습니다.',
|
||||||
|
|
@ -125,7 +119,6 @@ export const ko: TranslationSchema = {
|
||||||
setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!',
|
setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!',
|
||||||
createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성·설정했습니다!',
|
createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성·설정했습니다!',
|
||||||
},
|
},
|
||||||
|
|
||||||
voiceConfig: {
|
voiceConfig: {
|
||||||
description: '서버의 임시 음성 채널 설정을 관리합니다.',
|
description: '서버의 임시 음성 채널 설정을 관리합니다.',
|
||||||
setNameTitle: '기본 이름 템플릿 설정',
|
setNameTitle: '기본 이름 템플릿 설정',
|
||||||
|
|
@ -136,7 +129,6 @@ export const ko: TranslationSchema = {
|
||||||
templateLabel: '이름 템플릿',
|
templateLabel: '이름 템플릿',
|
||||||
limitLabel: '기본 인원 제한',
|
limitLabel: '기본 인원 제한',
|
||||||
setSuccess: '서버 임시 채널 설정이 업데이트되었습니다.',
|
setSuccess: '서버 임시 채널 설정이 업데이트되었습니다.',
|
||||||
|
|
||||||
limitValue: '{{limit}}명 (0 = 무제한)',
|
limitValue: '{{limit}}명 (0 = 무제한)',
|
||||||
},
|
},
|
||||||
language: {
|
language: {
|
||||||
|
|
@ -148,7 +140,6 @@ export const ko: TranslationSchema = {
|
||||||
userSet: '개인 언어가 **{{locale}}**(으)로 설정되었습니다.',
|
userSet: '개인 언어가 **{{locale}}**(으)로 설정되었습니다.',
|
||||||
serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.',
|
serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.',
|
||||||
serverPermissionDenied: '서버 언어 변경은 서버 관리자만 할 수 있습니다.',
|
serverPermissionDenied: '서버 언어 변경은 서버 관리자만 할 수 있습니다.',
|
||||||
|
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
description: '서버 이벤트 일정을 관리합니다.',
|
description: '서버 이벤트 일정을 관리합니다.',
|
||||||
|
|
@ -172,7 +163,6 @@ export const ko: TranslationSchema = {
|
||||||
'**시작 시각:** {{startsAt}}\n**상대 시간:** {{relative}}\n**상태:** {{status}}\n**리마인더:** {{reminder}}\n**채널:** {{channel}}',
|
'**시작 시각:** {{startsAt}}\n**상대 시간:** {{relative}}\n**상태:** {{status}}\n**리마인더:** {{reminder}}\n**채널:** {{channel}}',
|
||||||
cancelSuccess: '`{{id}}` 이벤트가 취소되었습니다.',
|
cancelSuccess: '`{{id}}` 이벤트가 취소되었습니다.',
|
||||||
cancelNotFound: 'ID가 `{{id}}`인 예약 이벤트를 찾을 수 없습니다.',
|
cancelNotFound: 'ID가 `{{id}}`인 예약 이벤트를 찾을 수 없습니다.',
|
||||||
|
|
||||||
announceSuccess: '`{{id}}` 이벤트를 {{channel}} 채널에 공지했습니다.',
|
announceSuccess: '`{{id}}` 이벤트를 {{channel}} 채널에 공지했습니다.',
|
||||||
announceNotAvailable: '이 이벤트에는 사용할 수 있는 공지 채널이 설정되어 있지 않습니다.',
|
announceNotAvailable: '이 이벤트에는 사용할 수 있는 공지 채널이 설정되어 있지 않습니다.',
|
||||||
startAnnouncementTitle: '이벤트 시작',
|
startAnnouncementTitle: '이벤트 시작',
|
||||||
|
|
@ -184,7 +174,6 @@ export const ko: TranslationSchema = {
|
||||||
'`0,10,60`처럼 0 이상의 분을 쉼표로 구분해 입력해 주세요. 비우면 자동 공지를 사용하지 않습니다.',
|
'`0,10,60`처럼 0 이상의 분을 쉼표로 구분해 입력해 주세요. 비우면 자동 공지를 사용하지 않습니다.',
|
||||||
invalidPastDateTime: '과거 시각으로는 이벤트를 예약할 수 없습니다.',
|
invalidPastDateTime: '과거 시각으로는 이벤트를 예약할 수 없습니다.',
|
||||||
invalidPastDateTimeResolution: '미래 시각을 선택한 뒤 다시 시도해 주세요.',
|
invalidPastDateTimeResolution: '미래 시각을 선택한 뒤 다시 시도해 주세요.',
|
||||||
|
|
||||||
statusScheduled: '예약됨',
|
statusScheduled: '예약됨',
|
||||||
statusCancelled: '취소됨',
|
statusCancelled: '취소됨',
|
||||||
statusCompleted: '완료됨',
|
statusCompleted: '완료됨',
|
||||||
|
|
@ -200,26 +189,6 @@ export const ko: TranslationSchema = {
|
||||||
status: '상태',
|
status: '상태',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
autorole: {
|
|
||||||
description: '입장 시 역할을 자동으로 부여하는 기능을 설정합니다.',
|
|
||||||
statusTitle: '자동 역할 부여 설정 상태',
|
|
||||||
userRoleLabel: '일반 유저 역할',
|
|
||||||
botRoleLabel: '봇 역할',
|
|
||||||
statusLabel: '유저 자동 부여',
|
|
||||||
botStatusLabel: '봇 자동 부여',
|
|
||||||
userRolePlaceholder: '유저 기본 역할을 선택하세요',
|
|
||||||
botRolePlaceholder: '봇 기본 역할을 선택하세요',
|
|
||||||
toggleUserEnable: '🟢 유저 자동부여 켜기',
|
|
||||||
toggleUserDisable: '🔴 유저 자동부여 끄기',
|
|
||||||
toggleBotEnable: '🟢 봇 자동부여 켜기',
|
|
||||||
toggleBotDisable: '🔴 봇 자동부여 끄기',
|
|
||||||
notSet: '미설정',
|
|
||||||
enabled: '활성',
|
|
||||||
disabled: '비활성',
|
|
||||||
updateSuccess: '자동 역할 설정이 업데이트되었습니다.',
|
|
||||||
permissionsError: '봇의 역할 순위가 낮거나 권한이 부족하여 역할을 부여할 수 없습니다.',
|
|
||||||
suspendNotice: '권한 부족으로 인해 자동 역할 부여 기능이 일시 중지되었습니다. 봇의 권한과 역할 순위를 확인해 주세요.',
|
|
||||||
},
|
|
||||||
music: {
|
music: {
|
||||||
description: '음성 채널에서 YouTube 오디오를 재생합니다.',
|
description: '음성 채널에서 YouTube 오디오를 재생합니다.',
|
||||||
addDescription: 'YouTube를 검색하거나 영상 URL을 재생 목록에 추가합니다.',
|
addDescription: 'YouTube를 검색하거나 영상 URL을 재생 목록에 추가합니다.',
|
||||||
|
|
@ -353,13 +322,13 @@ export const ko: TranslationSchema = {
|
||||||
summaryIssue: '{{fail}}건 실패 · {{warn}}건 경고가 있습니다.',
|
summaryIssue: '{{fail}}건 실패 · {{warn}}건 경고가 있습니다.',
|
||||||
hierarchyWarning:
|
hierarchyWarning:
|
||||||
"봇 역할(위치: {{botPos}})이 '{{role}}'(위치: {{targetPos}})보다 위에 있어야 해당 역할을 관리할 수 있습니다.",
|
"봇 역할(위치: {{botPos}})이 '{{role}}'(위치: {{targetPos}})보다 위에 있어야 해당 역할을 관리할 수 있습니다.",
|
||||||
|
|
||||||
features: {
|
features: {
|
||||||
BASIC: '기본 봇 기능',
|
BASIC: '기본 봇 기능',
|
||||||
VOICE_GLOBAL: '임시 음성 채널 (전역)',
|
VOICE_GLOBAL: '임시 음성 채널 (전역)',
|
||||||
VOICE_GENERATOR_CHANNEL: '음성 생성기 채널',
|
VOICE_GENERATOR_CHANNEL: '음성 생성기 채널',
|
||||||
VOICE_GENERATOR_CATEGORY: '음성 생성기 카테고리',
|
VOICE_GENERATOR_CATEGORY: '음성 생성기 카테고리',
|
||||||
|
INVITE_TRACKING: '초대 추적',
|
||||||
|
INVITE_ROLE_HIERARCHY: '초대 역할 부여 (계층 검사)',
|
||||||
MIMIC_WEBHOOK: '메시지 흉내 (Webhook)',
|
MIMIC_WEBHOOK: '메시지 흉내 (Webhook)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -416,13 +385,12 @@ export const ko: TranslationSchema = {
|
||||||
expired: '시간이 만료되었습니다. `/setup`을 다시 실행해 주세요.',
|
expired: '시간이 만료되었습니다. `/setup`을 다시 실행해 주세요.',
|
||||||
defaultCategoryName: '음성 채널',
|
defaultCategoryName: '음성 채널',
|
||||||
defaultGeneratorName: '채널 생성하기',
|
defaultGeneratorName: '채널 생성하기',
|
||||||
|
|
||||||
auditCategories: {
|
auditCategories: {
|
||||||
SYSTEM: '시스템',
|
SYSTEM: '시스템',
|
||||||
BOOT: '부팅',
|
BOOT: '부팅',
|
||||||
VOICE: '음성',
|
VOICE: '음성',
|
||||||
PERMISSION: '권한',
|
PERMISSION: '권한',
|
||||||
|
INVITE: '초대',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
|
|
@ -430,20 +398,17 @@ export const ko: TranslationSchema = {
|
||||||
noOptions: '변경할 옵션을 하나 이상 선택해 주세요.',
|
noOptions: '변경할 옵션을 하나 이상 선택해 주세요.',
|
||||||
mimic: {
|
mimic: {
|
||||||
label: '흉내(Mimic)',
|
label: '흉내(Mimic)',
|
||||||
enabled: '활성',
|
enabled: '활성화',
|
||||||
disabled: '비활성',
|
disabled: '비활성화',
|
||||||
},
|
},
|
||||||
emoji: {
|
emoji: {
|
||||||
label: '큰 이모지(Big Emoji)',
|
label: '큰 이모지(Big Emoji)',
|
||||||
enabled: '활성',
|
enabled: '활성화',
|
||||||
disabled: '비활성',
|
disabled: '비활성화',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
// ── 모달 ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
modals: {
|
modals: {
|
||||||
renameTitle: '음성 채널 이름 변경',
|
renameTitle: '음성 채널 이름 변경',
|
||||||
renameLabel: '새 채널 이름',
|
renameLabel: '새 채널 이름',
|
||||||
|
|
@ -451,16 +416,12 @@ export const ko: TranslationSchema = {
|
||||||
limitLabel: '인원 제한 (0 = 무제한, 1–99)',
|
limitLabel: '인원 제한 (0 = 무제한, 1–99)',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 셀렉트 메뉴 플레이스홀더 ────────────────────────────
|
|
||||||
|
|
||||||
selects: {
|
selects: {
|
||||||
kickUser: '추방할 유저를 선택하세요',
|
kickUser: '추방할 유저를 선택하세요',
|
||||||
banUser: '차단할 유저를 선택하세요',
|
banUser: '차단할 유저를 선택하세요',
|
||||||
transferOwner: '소유권을 이전할 유저를 선택하세요',
|
transferOwner: '소유권을 이전할 유저를 선택하세요',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 상태 메시지 ──────────────────────────────────────────
|
|
||||||
|
|
||||||
presence: {
|
presence: {
|
||||||
servers: '{{guildCount}}개의 서버에서 작동 중',
|
servers: '{{guildCount}}개의 서버에서 작동 중',
|
||||||
help: '/help 명령어를 확인하세요',
|
help: '/help 명령어를 확인하세요',
|
||||||
|
|
|
||||||
|
|
@ -145,26 +145,6 @@ export interface TranslationSchema {
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
autorole: {
|
|
||||||
description: string;
|
|
||||||
statusTitle: string;
|
|
||||||
userRoleLabel: string;
|
|
||||||
botRoleLabel: string;
|
|
||||||
statusLabel: string;
|
|
||||||
botStatusLabel: string;
|
|
||||||
userRolePlaceholder: string;
|
|
||||||
botRolePlaceholder: string;
|
|
||||||
toggleUserEnable: string;
|
|
||||||
toggleUserDisable: string;
|
|
||||||
toggleBotEnable: string;
|
|
||||||
toggleBotDisable: string;
|
|
||||||
notSet: string;
|
|
||||||
enabled: string;
|
|
||||||
disabled: string;
|
|
||||||
updateSuccess: string;
|
|
||||||
permissionsError: string;
|
|
||||||
suspendNotice: string;
|
|
||||||
};
|
|
||||||
music: {
|
music: {
|
||||||
description: string;
|
description: string;
|
||||||
addDescription: string;
|
addDescription: string;
|
||||||
|
|
@ -302,6 +282,8 @@ export interface TranslationSchema {
|
||||||
VOICE_GLOBAL: string;
|
VOICE_GLOBAL: string;
|
||||||
VOICE_GENERATOR_CHANNEL: string;
|
VOICE_GENERATOR_CHANNEL: string;
|
||||||
VOICE_GENERATOR_CATEGORY: string;
|
VOICE_GENERATOR_CATEGORY: string;
|
||||||
|
INVITE_TRACKING: string;
|
||||||
|
INVITE_ROLE_HIERARCHY: string;
|
||||||
MIMIC_WEBHOOK: string;
|
MIMIC_WEBHOOK: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -323,6 +305,7 @@ export interface TranslationSchema {
|
||||||
BOOT: string;
|
BOOT: string;
|
||||||
VOICE: string;
|
VOICE: string;
|
||||||
PERMISSION: string;
|
PERMISSION: string;
|
||||||
|
INVITE: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
config: {
|
config: {
|
||||||
|
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
import {
|
|
||||||
SlashCommandBuilder,
|
|
||||||
SlashCommandOptionsOnlyBuilder,
|
|
||||||
SlashCommandSubcommandsOnlyBuilder,
|
|
||||||
ChatInputCommandInteraction,
|
|
||||||
} from 'discord.js';
|
|
||||||
|
|
||||||
/** Values returned from {@link SlashCommandBuilder} after chaining options or subcommands only. */
|
|
||||||
export type SlashCommandData =
|
|
||||||
| SlashCommandBuilder
|
|
||||||
| SlashCommandOptionsOnlyBuilder
|
|
||||||
| SlashCommandSubcommandsOnlyBuilder;
|
|
||||||
import { prisma } from '../database';
|
|
||||||
import { SupportedLocale } from '../i18n';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 명령의 도메인·특성 구분입니다.
|
|
||||||
*
|
|
||||||
* - 같은 특성끼리 묶어 help 표시, 권한 정책, 기능 플래그, 통계 등에 활용할 수 있습니다.
|
|
||||||
* - `Command`를 쓰지 않는 기존 명령 모듈에는 값이 없을 수 있습니다.
|
|
||||||
*/
|
|
||||||
export enum CommandTrait {
|
|
||||||
/** 음악 재생·대기열 등 */
|
|
||||||
Music = 'music',
|
|
||||||
/** 미니게임 */
|
|
||||||
Minigame = 'minigame',
|
|
||||||
/** 방송 연동·알림 등 */
|
|
||||||
Broadcast = 'broadcast',
|
|
||||||
/** 위에 해당하지 않는 일반 명령(설정·감사·음성 등) */
|
|
||||||
General = 'general',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** {@link CommandTrait.General}을 제외한 특성은 `guild_payment` 플래그가 필요합니다. */
|
|
||||||
export function traitRequiresPayment(trait: CommandTrait): boolean {
|
|
||||||
return (
|
|
||||||
trait === CommandTrait.Music || trait === CommandTrait.Minigame || trait === CommandTrait.Broadcast
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 유료 특성 명령에 대해 길드 결제 플래그를 확인합니다.
|
|
||||||
*
|
|
||||||
* @returns 통과 시 `true`. 이미 `reply`로 응답한 경우 `false`.
|
|
||||||
*/
|
|
||||||
export async function ensureGuildPaidForTrait(
|
|
||||||
interaction: ChatInputCommandInteraction,
|
|
||||||
trait: CommandTrait,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!traitRequiresPayment(trait)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const guildId = interaction.guildId;
|
|
||||||
if (!guildId) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: '이 명령은 서버에서만 사용할 수 있습니다.',
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = await prisma.guildPayment.findUnique({ where: { id: guildId } });
|
|
||||||
const paid =
|
|
||||||
row != null &&
|
|
||||||
((trait === CommandTrait.Music && row.music) ||
|
|
||||||
(trait === CommandTrait.Minigame && row.minigame) ||
|
|
||||||
(trait === CommandTrait.Broadcast && row.broadcast));
|
|
||||||
|
|
||||||
if (!paid) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: '결제가 되지않았습니다',
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 슬래시 명령을 `CommandLoader`에 넘길 때 쓰는 모듈 형태입니다.
|
|
||||||
*
|
|
||||||
* `src/handlers/CommandLoader.ts`는 각 명령 파일의 `default` export가
|
|
||||||
* `data`(슬래시 정의)와 `execute`(실행 함수) 속성을 가지는지 검사한 뒤,
|
|
||||||
* `client.commands.set(command.data.name, command)`로 등록합니다.
|
|
||||||
* 따라서 이 타입은 디스코드에 올리는 빌더와, 실제 처리 진입점을 한 묶음으로 맞춥니다.
|
|
||||||
*
|
|
||||||
* `trait`은 {@link Command.toModule}을 통해 붙이며, 특성 필터링 시 사용합니다.
|
|
||||||
*/
|
|
||||||
export type CommandModule = {
|
|
||||||
data: SlashCommandData;
|
|
||||||
execute: (interaction: ChatInputCommandInteraction, locale: SupportedLocale) => Promise<void>;
|
|
||||||
trait?: CommandTrait;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 슬래시 명령용 추상 베이스 클래스입니다.
|
|
||||||
*
|
|
||||||
* **사용 흐름**
|
|
||||||
* 1. 이 클래스를 상속한 뒤 {@link trait}에 {@link CommandTrait} 중 하나를 지정합니다.
|
|
||||||
* 2. {@link define}에서 `SlashCommandBuilder`로 이름·설명·옵션·권한 등을 구성합니다.
|
|
||||||
* 3. {@link handle}에서 실제 비즈니스 로직(답장·DB·서비스 호출)을 작성합니다.
|
|
||||||
* 4. `export default new MyCommand().toModule()`처럼 `toModule()` 결과를 기본 내보내기 하면
|
|
||||||
* 기존 `commands/*.ts`와 동일하게 로더에 잡힙니다(`trait` 메타데이터 포함).
|
|
||||||
*
|
|
||||||
* **실행 순서** (`interactionCreate` → 본 클래스의 `execute`)
|
|
||||||
* 1. `guildOnly === true`이면 DM 등 길드가 아닌 경우 즉시 안내 메시지 후 종료합니다.
|
|
||||||
* 2. {@link CommandTrait.Music} / {@link CommandTrait.Minigame} / {@link CommandTrait.Broadcast}인 경우
|
|
||||||
* `guild_payment` 테이블을 조회해 해당 플래그가 `true`일 때만 진행합니다. (행 없음·`false` → 「결제가 되지않았습니다」)
|
|
||||||
* 3. {@link beforeHandle}이 `false`를 반환하면(이미 응답을 보낸 경우 등) {@link handle}은 호출되지 않습니다.
|
|
||||||
* 4. 그렇지 않으면 {@link handle}을 실행합니다.
|
|
||||||
*
|
|
||||||
* `events/interactionCreate.ts`는 로케일을 resolve한 뒤 `command.execute(interaction, locale)`만 호출하므로,
|
|
||||||
* 명령 파일마다 반복되던 길드-only 같은 공통 처리는 이 클래스에 모을 수 있습니다.
|
|
||||||
*/
|
|
||||||
export abstract class Command {
|
|
||||||
private cachedData: SlashCommandData | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이 명령의 도메인 특성입니다. 서브클래스에서 반드시 한 값으로 고정하세요.
|
|
||||||
*
|
|
||||||
* 예: 음악 명령 → {@link CommandTrait.Music}, 미니게임 → {@link CommandTrait.Minigame},
|
|
||||||
* 방송 → {@link CommandTrait.Broadcast}, 그 외 → {@link CommandTrait.General}.
|
|
||||||
*/
|
|
||||||
protected abstract readonly trait: CommandTrait;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `true`이면 **서버(길드) 안에서만** 명령이 동작합니다.
|
|
||||||
*
|
|
||||||
* DM이나 길드 컨텍스트가 없는 상호작용에서는 {@link handle}에 들어가기 전에
|
|
||||||
* 영문 안내를 다른 사용자에게는 보이지 않는(ephemeral) 형태로 보내고 return 합니다.
|
|
||||||
* 서버 전용 명령(채널·역할·길드 설정 등)에 켜 두면 됩니다.
|
|
||||||
*/
|
|
||||||
protected guildOnly = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 디스코드에 등록할 슬래시 명령 빌더입니다.
|
|
||||||
*
|
|
||||||
* 최초 접근 시 {@link define}을 한 번만 호출해 결과를 캐시합니다.
|
|
||||||
* 같은 인스턴스에서 `data`를 여러 번 읽어도 빌더는 한 번만 만들어집니다.
|
|
||||||
*/
|
|
||||||
get data(): SlashCommandData {
|
|
||||||
if (!this.cachedData) {
|
|
||||||
this.cachedData = this.define();
|
|
||||||
}
|
|
||||||
return this.cachedData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 슬래시 명령의 정의(이름, 설명, 로컬라이즈, 서브커맨드, 옵션, `setDefaultMemberPermissions` 등)를
|
|
||||||
* `SlashCommandBuilder`로 구성해 반환합니다.
|
|
||||||
*/
|
|
||||||
protected abstract define(): SlashCommandData;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 공통 가드를 통과한 뒤 실행되는 본 처리입니다.
|
|
||||||
*
|
|
||||||
* `interaction`은 채팅 입력 슬래시이고, `locale`은 사용자/길드 설정을 반영해 resolve된 값입니다.
|
|
||||||
*/
|
|
||||||
protected abstract handle(
|
|
||||||
interaction: ChatInputCommandInteraction,
|
|
||||||
locale: SupportedLocale,
|
|
||||||
): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@link handle} 직전에 한 번 호출되는 선택적 훅입니다.
|
|
||||||
*
|
|
||||||
* 권한 검사, rate limit, 잘못된 옵션 조합에 대한 조기 응답 등 **여러 명령에서 공통으로 쓸 선행 로직**을
|
|
||||||
* 넣기 좋습니다.
|
|
||||||
*
|
|
||||||
* @returns `true`이면 그대로 {@link handle}으로 진행합니다.
|
|
||||||
* `false`이면 **이미 `reply`/`deferReply` 등으로 응답을 끝낸 것으로 간주**하고
|
|
||||||
* `handle`은 호출하지 않습니다.
|
|
||||||
*/
|
|
||||||
protected async beforeHandle(
|
|
||||||
_interaction: ChatInputCommandInteraction,
|
|
||||||
_locale: SupportedLocale,
|
|
||||||
): Promise<boolean> {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로더/디스코드가 호출하는 진입점입니다.
|
|
||||||
*
|
|
||||||
* 길드-only 검사 → 유료 특성 시 {@link ensureGuildPaidForTrait} → `beforeHandle` → `handle` 순으로 위임합니다.
|
|
||||||
* 일반적으로 서브클래스에서 이 메서드를 직접 오버라이드할 필요는 없습니다.
|
|
||||||
*/
|
|
||||||
async execute(
|
|
||||||
interaction: ChatInputCommandInteraction,
|
|
||||||
locale: SupportedLocale,
|
|
||||||
): Promise<void> {
|
|
||||||
if (this.guildOnly && !interaction.inGuild()) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: 'This command can only be used in a server.',
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await ensureGuildPaidForTrait(interaction, this.trait))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await this.beforeHandle(interaction, locale))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.handle(interaction, locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `CommandModule` 형태로 묶어 `default export`에 넘깁니다.
|
|
||||||
*
|
|
||||||
* `execute`는 이 인스턴스에 바인딩되므로, 서브클래스에서 `this`를 쓰는 메서드도 그대로 동작합니다.
|
|
||||||
*/
|
|
||||||
toModule(): CommandModule {
|
|
||||||
return {
|
|
||||||
data: this.data,
|
|
||||||
execute: (interaction, locale) => this.execute(interaction, locale),
|
|
||||||
trait: this.trait,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
/**
|
|
||||||
* `Command` 사용 예시 (참고용).
|
|
||||||
*
|
|
||||||
* 이 파일은 `handlers/CommandLoader`가 읽는 `src/commands/` 밖에 있어서
|
|
||||||
* 디스코드에 자동 등록되지 않습니다. 실제 명령으로 쓰려면 이 내용을
|
|
||||||
* `src/commands/your-command.ts`로 옮기고 `setName`을 고유 이름으로 바꾸세요.
|
|
||||||
*
|
|
||||||
* 특성은 {@link CommandTrait.Music}이라 `guild_payment.music === true`일 때만
|
|
||||||
* 본 처리까지 진행됩니다(미결제 시 「결제가 되지않았습니다」).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
SlashCommandBuilder,
|
|
||||||
ChatInputCommandInteraction,
|
|
||||||
SlashCommandStringOption,
|
|
||||||
} from 'discord.js';
|
|
||||||
import { Command, CommandTrait } from './command';
|
|
||||||
import { SupportedLocale } from '../i18n';
|
|
||||||
|
|
||||||
class ExampleSlashCommand extends Command {
|
|
||||||
/** 음악 유료 특성 — DB `guild_payment.music` 플래그를 검사합니다. */
|
|
||||||
protected readonly trait = CommandTrait.Music;
|
|
||||||
|
|
||||||
/** 길드에서만 쓰도록 기본 가드 사용 */
|
|
||||||
protected guildOnly = true;
|
|
||||||
|
|
||||||
protected define() {
|
|
||||||
return (
|
|
||||||
new SlashCommandBuilder()
|
|
||||||
.setName('command_usage_demo')
|
|
||||||
.setDescription('Example: Command base class usage (not registered from this path).')
|
|
||||||
.setDescriptionLocalizations({
|
|
||||||
ko: '예시: Command 베이스 클래스 사용법 (이 경로에서는 등록되지 않음).',
|
|
||||||
})
|
|
||||||
// 서브커맨드/옵션은 기존과 같이 붙이면 됩니다.
|
|
||||||
.addStringOption((option: SlashCommandStringOption) =>
|
|
||||||
option
|
|
||||||
.setName('message')
|
|
||||||
.setDescription('Echo text')
|
|
||||||
.setRequired(false),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 공통 선행 검사가 필요하면 `beforeHandle`을 오버라이드합니다.
|
|
||||||
* `false`를 반환하면 이미 응답을 보낸 뒤이므로 `handle`은 실행되지 않습니다.
|
|
||||||
*/
|
|
||||||
protected async beforeHandle(
|
|
||||||
interaction: ChatInputCommandInteraction,
|
|
||||||
_locale: SupportedLocale,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const msg = interaction.options.getString('message');
|
|
||||||
if (msg === 'block') {
|
|
||||||
await interaction.reply({ content: 'Blocked by beforeHandle.', ephemeral: true });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async handle(
|
|
||||||
interaction: ChatInputCommandInteraction,
|
|
||||||
locale: SupportedLocale,
|
|
||||||
): Promise<void> {
|
|
||||||
const message = interaction.options.getString('message');
|
|
||||||
const guildName = interaction.guild?.name ?? 'unknown';
|
|
||||||
const line =
|
|
||||||
message != null && message.length > 0
|
|
||||||
? `[${locale}] **${guildName}** — ${message}`
|
|
||||||
: `[${locale}] **${guildName}** — (no message option)`;
|
|
||||||
|
|
||||||
await interaction.reply({ content: line, ephemeral: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `commands/*.ts`에서 쓰는 것과 동일한 내보내기 형태:
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* export default new ExampleSlashCommand().toModule();
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export default new ExampleSlashCommand().toModule();
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { prisma } from '../database';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
|
|
||||||
export type AuditSeverity = 'INFO' | 'WARN' | 'ERROR';
|
export type AuditSeverity = 'INFO' | 'WARN' | 'ERROR';
|
||||||
export type AuditCategory = 'SYSTEM' | 'BOOT' | 'VOICE' | 'PERMISSION' | 'MIMIC';
|
export type AuditCategory = 'SYSTEM' | 'BOOT' | 'VOICE' | 'PERMISSION' | 'INVITE' | 'MIMIC';
|
||||||
|
|
||||||
export interface AuditLogPayload {
|
export interface AuditLogPayload {
|
||||||
category: AuditCategory;
|
category: AuditCategory;
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import { Guild, GuildMember, PermissionFlagsBits } from 'discord.js';
|
|
||||||
import { prisma } from '../database';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
import { auditLogService } from './AuditLogService';
|
|
||||||
|
|
||||||
export class AutoRoleService {
|
|
||||||
/**
|
|
||||||
* 서버의 자동 역할 설정을 조회합니다.
|
|
||||||
*/
|
|
||||||
async getConfig(guildId: string) {
|
|
||||||
return prisma.autoRoleConfig.findUnique({
|
|
||||||
where: { guildId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 서버의 자동 역할 설정을 업데이트합니다.
|
|
||||||
*/
|
|
||||||
async updateConfig(guildId: string, data: {
|
|
||||||
userRoleIds?: string[];
|
|
||||||
botRoleIds?: string[];
|
|
||||||
isEnabled?: boolean;
|
|
||||||
botEnabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return prisma.autoRoleConfig.upsert({
|
|
||||||
where: { guildId },
|
|
||||||
create: {
|
|
||||||
guildId,
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
update: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 자동 역할 부여 기능을 활성/비활성합니다.
|
|
||||||
*/
|
|
||||||
async setEnabled(guildId: string, enabled: boolean) {
|
|
||||||
return this.updateConfig(guildId, { isEnabled: enabled });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 신규 멤버가 입장했을 때 자동으로 역할을 부여합니다.
|
|
||||||
*/
|
|
||||||
async handleMemberJoin(member: GuildMember) {
|
|
||||||
const config = await this.getConfig(member.guild.id);
|
|
||||||
if (!config) return;
|
|
||||||
|
|
||||||
const isBot = member.user.bot;
|
|
||||||
const isEnabled = isBot ? config.botEnabled : config.isEnabled;
|
|
||||||
const roleIds = isBot ? config.botRoleIds : config.userRoleIds;
|
|
||||||
|
|
||||||
if (!isEnabled || roleIds.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await member.roles.add(roleIds, 'Kord Auto-Role');
|
|
||||||
logger.info(`[AutoRole] Added roles to ${member.user.tag} in ${member.guild.name}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[AutoRole] Failed to add roles to ${member.user.tag} in ${member.guild.name}`, error);
|
|
||||||
|
|
||||||
// 권한 문제인 경우 감사 로그에 기록
|
|
||||||
await auditLogService.log(member.guild, {
|
|
||||||
category: 'PERMISSION',
|
|
||||||
severity: 'WARN',
|
|
||||||
title: 'Auto-Role Failure',
|
|
||||||
description: `Failed to assign roles to ${member.user.toString()} automatically. Please check the bot's permission and role hierarchy.`
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const autoRoleService = new AutoRoleService();
|
|
||||||
|
|
@ -161,7 +161,7 @@ export class EventService {
|
||||||
|
|
||||||
const diff = event.startsAt.getTime() - now.getTime();
|
const diff = event.startsAt.getTime() - now.getTime();
|
||||||
|
|
||||||
const dueOffsets = event.reminderOffsets.filter((offset: number) =>
|
const dueOffsets = event.reminderOffsets.filter(offset =>
|
||||||
offset > 0 &&
|
offset > 0 &&
|
||||||
!event.sentReminderOffsets.includes(offset) &&
|
!event.sentReminderOffsets.includes(offset) &&
|
||||||
diff <= offset * 60 * 1000 &&
|
diff <= offset * 60 * 1000 &&
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { Client, Guild, Invite, GuildMember } from 'discord.js';
|
||||||
|
import { prisma } from '../database';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
export class InviteService {
|
||||||
|
/** In-process invite snapshot per guild for join attribution. */
|
||||||
|
private static readonly inviteCache = new Map<string, string>();
|
||||||
|
|
||||||
|
public static async cacheAllInvites(client: Client) {
|
||||||
|
for (const [, guild] of client.guilds.cache) {
|
||||||
|
await this.cacheGuildInvites(guild);
|
||||||
|
}
|
||||||
|
logger.info('InviteMaster: Finished caching all invites.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async cacheGuildInvites(guild: Guild) {
|
||||||
|
try {
|
||||||
|
const invites = await guild.invites.fetch();
|
||||||
|
const inviteData = invites.map(inv => ({
|
||||||
|
code: inv.code,
|
||||||
|
uses: inv.uses || 0
|
||||||
|
}));
|
||||||
|
this.inviteCache.set(guild.id, JSON.stringify(inviteData));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async handleInviteCreate(invite: Invite) {
|
||||||
|
if (!invite.guild) return;
|
||||||
|
logger.debug(`InviteMaster: New invite created: ${invite.code}`);
|
||||||
|
await this.cacheGuildInvites(invite.guild as Guild);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async handleInviteDelete(invite: Invite) {
|
||||||
|
if (!invite.guild) return;
|
||||||
|
logger.debug(`InviteMaster: Invite deleted: ${invite.code}`);
|
||||||
|
await this.cacheGuildInvites(invite.guild as Guild);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async handleMemberAdd(member: GuildMember) {
|
||||||
|
const guild = member.guild;
|
||||||
|
try {
|
||||||
|
// Fetch current active invites
|
||||||
|
const newInvites = await guild.invites.fetch();
|
||||||
|
const cachedData = this.inviteCache.get(guild.id);
|
||||||
|
|
||||||
|
let usedInvite: Invite | undefined;
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
const cachedInvites: { code: string, uses: number }[] = JSON.parse(cachedData);
|
||||||
|
|
||||||
|
// Find the invite where 'uses' has increased
|
||||||
|
usedInvite = newInvites.find(inv => {
|
||||||
|
const cached = cachedInvites.find(c => c.code === inv.code);
|
||||||
|
return cached ? (inv.uses || 0) > cached.uses : false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the cache immediately to account for this new join
|
||||||
|
await this.cacheGuildInvites(guild);
|
||||||
|
|
||||||
|
if (usedInvite) {
|
||||||
|
logger.info(`InviteMaster: ${member.user.tag} joined using invite ${usedInvite.code}`);
|
||||||
|
|
||||||
|
// Check DB for mapped role
|
||||||
|
const inviteRole = await prisma.inviteRole.findFirst({
|
||||||
|
where: {
|
||||||
|
guildId: guild.id,
|
||||||
|
inviteCode: usedInvite.code
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inviteRole) {
|
||||||
|
const role = guild.roles.cache.get(inviteRole.roleId);
|
||||||
|
if (role) {
|
||||||
|
await member.roles.add(role);
|
||||||
|
logger.info(`InviteMaster: Assigned role ${role.name} to ${member.user.tag}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`InviteMaster: Role ${inviteRole.roleId} mapped to invite ${usedInvite.code} not found.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`InviteMaster: ${member.user.tag} joined but invite could not be determined (ex: Vanity URL).`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`InviteMaster: Failed to handle member add tracking:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,7 +88,7 @@ const FEATURE_DEFINITIONS: FeatureDefinition[] = [
|
||||||
],
|
],
|
||||||
resolveChannelIds: async (guildId) => {
|
resolveChannelIds: async (guildId) => {
|
||||||
const generators = await prisma.voiceGenerator.findMany({ where: { guildId } });
|
const generators = await prisma.voiceGenerator.findMany({ where: { guildId } });
|
||||||
return generators.map((g: { channelId: string }) => g.channelId);
|
return generators.map((g) => g.channelId);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -103,13 +103,28 @@ const FEATURE_DEFINITIONS: FeatureDefinition[] = [
|
||||||
],
|
],
|
||||||
resolveChannelIds: async (guildId) => {
|
resolveChannelIds: async (guildId) => {
|
||||||
const generators = await prisma.voiceGenerator.findMany({ where: { guildId } });
|
const generators = await prisma.voiceGenerator.findMany({ where: { guildId } });
|
||||||
return generators
|
return generators.map((g) => g.categoryId).filter((id): id is string => id !== null);
|
||||||
.map((g: { categoryId: string | null }) => g.categoryId)
|
|
||||||
.filter((id: string | null): id is string => id !== null);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 5. 메시지 흉내 (Mimic) ──
|
// ── 5. 초대 추적 ──
|
||||||
|
{
|
||||||
|
featureKey: 'INVITE_TRACKING',
|
||||||
|
scope: 'guild',
|
||||||
|
permissions: [PermissionFlagsBits.ManageGuild],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 6. 역할 자동 부여 (초대 연동) - 계층 검사 ──
|
||||||
|
{
|
||||||
|
featureKey: 'INVITE_ROLE_HIERARCHY',
|
||||||
|
scope: 'hierarchy',
|
||||||
|
resolveTargetRoleIds: async (guildId) => {
|
||||||
|
const inviteRoles = await prisma.inviteRole.findMany({ where: { guildId } });
|
||||||
|
return inviteRoles.map((ir) => ir.roleId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 7. 메시지 흉내 (Mimic) ──
|
||||||
{
|
{
|
||||||
featureKey: 'MIMIC_WEBHOOK',
|
featureKey: 'MIMIC_WEBHOOK',
|
||||||
scope: 'guild',
|
scope: 'guild',
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } from 'discord.js';
|
import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } from 'discord.js';
|
||||||
import { prisma } from '../database';
|
import { prisma } from '../database';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
|
||||||
import { ErrorDefs } from '../errors/ErrorCodes';
|
import { ErrorDefs } from '../errors/ErrorCodes';
|
||||||
import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n';
|
import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n';
|
||||||
import { getContextLocale } from '../i18n/localeHelper';
|
import { getContextLocale } from '../i18n/localeHelper';
|
||||||
|
|
@ -18,7 +16,6 @@ export class VoiceService {
|
||||||
const guild = client.guilds.cache.get(temp.guildId) || await client.guilds.fetch(temp.guildId).catch(() => null);
|
const guild = client.guilds.cache.get(temp.guildId) || await client.guilds.fetch(temp.guildId).catch(() => null);
|
||||||
if (!guild) continue;
|
if (!guild) continue;
|
||||||
|
|
||||||
|
|
||||||
const channel = guild.channels.cache.get(temp.channelId) || await guild.channels.fetch(temp.channelId).catch(() => null);
|
const channel = guild.channels.cache.get(temp.channelId) || await guild.channels.fetch(temp.channelId).catch(() => null);
|
||||||
|
|
||||||
if (!channel || channel.type !== ChannelType.GuildVoice) {
|
if (!channel || channel.type !== ChannelType.GuildVoice) {
|
||||||
|
|
@ -48,7 +45,6 @@ export class VoiceService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.info('VoiceService: Channel synchronization complete.');
|
logger.info('VoiceService: Channel synchronization complete.');
|
||||||
|
|
||||||
}
|
}
|
||||||
public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
|
public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
|
||||||
const member = newState.member;
|
const member = newState.member;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { TextChannel, WebhookClient } from 'discord.js';
|
import { TextChannel, WebhookClient } from 'discord.js';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export class WebhookService {
|
export class WebhookService {
|
||||||
private static readonly MAX_WEBHOOKS = 10;
|
private static readonly MAX_WEBHOOKS = 10;
|
||||||
private static readonly WEBHOOK_NAME = 'Kord Mimic Webhook';
|
private static readonly WEBHOOK_NAME = 'Kord Mimic Webhook';
|
||||||
|
|
@ -18,7 +16,6 @@ export class WebhookService {
|
||||||
const cached = this.webhookCache.get(channel.id);
|
const cached = this.webhookCache.get(channel.id);
|
||||||
if (cached && now < cached.expiresAt) {
|
if (cached && now < cached.expiresAt) {
|
||||||
return new WebhookClient({ id: cached.id, token: cached.token });
|
return new WebhookClient({ id: cached.id, token: cached.token });
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch from Discord API
|
// 2. Fetch from Discord API
|
||||||
|
|
@ -51,7 +48,6 @@ export class WebhookService {
|
||||||
id: kordWebhook.id,
|
id: kordWebhook.id,
|
||||||
token: kordWebhook.token,
|
token: kordWebhook.token,
|
||||||
expiresAt: Date.now() + this.WEBHOOK_CACHE_TTL_MS,
|
expiresAt: Date.now() + this.WEBHOOK_CACHE_TTL_MS,
|
||||||
|
|
||||||
});
|
});
|
||||||
return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token });
|
return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,6 @@ ensureLogDir(logDir);
|
||||||
|
|
||||||
log4js.configure({
|
log4js.configure({
|
||||||
appenders: {
|
appenders: {
|
||||||
console: {
|
|
||||||
type: 'stdout',
|
|
||||||
layout: {
|
|
||||||
type: 'pattern',
|
|
||||||
pattern: '%[[%p]%] %m',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
file: {
|
file: {
|
||||||
type: 'dateFile',
|
type: 'dateFile',
|
||||||
filename: resolve(logDir, 'kord.log'),
|
filename: resolve(logDir, 'kord.log'),
|
||||||
|
|
@ -57,7 +50,7 @@ log4js.configure({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
default: { appenders: ['console', 'file'], level },
|
default: { appenders: ['file'], level },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
jest.mock('../../src/database', () => {
|
|
||||||
const tx = { __tx: true };
|
|
||||||
const prisma = {
|
|
||||||
$transaction: jest.fn(async (fn: (tx: unknown) => Promise<unknown>) => fn(tx)),
|
|
||||||
};
|
|
||||||
return { prisma };
|
|
||||||
});
|
|
||||||
|
|
||||||
import { prisma } from '../../src/database';
|
|
||||||
import { transaction, withTransaction } from '../../src/core/db';
|
|
||||||
|
|
||||||
describe('core/db transaction helpers', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
(prisma.$transaction as jest.Mock).mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transaction() executes callback via prisma.$transaction', async () => {
|
|
||||||
const result = await transaction(async (_tx) => {
|
|
||||||
return 123;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe(123);
|
|
||||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transaction() propagates error (rollback is handled by Prisma)', async () => {
|
|
||||||
await expect(
|
|
||||||
transaction(async () => {
|
|
||||||
throw new Error('boom');
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('boom');
|
|
||||||
|
|
||||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('withTransaction() starts a new transaction for root client', async () => {
|
|
||||||
const rootClient = prisma as unknown as { $transaction: (fn: any) => Promise<any> };
|
|
||||||
|
|
||||||
const result = await withTransaction(rootClient as any, async (_tx) => 'ok');
|
|
||||||
|
|
||||||
expect(result).toBe('ok');
|
|
||||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('withTransaction() reuses existing tx client (does not nest)', async () => {
|
|
||||||
const txClient = {} as any; // does not have $transaction
|
|
||||||
|
|
||||||
const result = await withTransaction(txClient, async (_tx) => 'ok');
|
|
||||||
|
|
||||||
expect(result).toBe('ok');
|
|
||||||
expect(prisma.$transaction).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
240
yarn.lock
240
yarn.lock
|
|
@ -573,30 +573,30 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@emnapi/core@npm:^1.4.3":
|
"@emnapi/core@npm:^1.4.3":
|
||||||
version: 1.9.2
|
version: 1.9.1
|
||||||
resolution: "@emnapi/core@npm:1.9.2"
|
resolution: "@emnapi/core@npm:1.9.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@emnapi/wasi-threads": "npm:1.2.1"
|
"@emnapi/wasi-threads": "npm:1.2.0"
|
||||||
tslib: "npm:^2.4.0"
|
tslib: "npm:^2.4.0"
|
||||||
checksum: 10c0/5500393f953951bad0768fafaa9191f2d938956b20c6d6a79e5ab696a613a25ce6ad23422bc18e86e6ce8deb147619d8d0d7d413a69f84adc01a6633cc353cd9
|
checksum: 10c0/00e7a99a2bc3ad908ca8272ba861a934da87dffa8797a41316c4a3b571a1e4d2743e2fa14b1a0f131fa4a3c2018ddb601cd2a8cb7f574fa940af696df3c2fe8d
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.7.0":
|
"@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.7.0":
|
||||||
version: 1.9.2
|
version: 1.9.1
|
||||||
resolution: "@emnapi/runtime@npm:1.9.2"
|
resolution: "@emnapi/runtime@npm:1.9.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: "npm:^2.4.0"
|
tslib: "npm:^2.4.0"
|
||||||
checksum: 10c0/61c3a59e0c36784558b8d58eb02bd04815aa5fb0dbfbaf84d1b3050a78aa0cc63ea129ae806bd1e48062bfeb7fc36eb0e5431740d62f64ea51bdf426404b8caa
|
checksum: 10c0/750edca117e0363ab2de10622f8ee60e57d8690c2f29c49704813da5cd627c641798d7f3cb0d953c62fdc71688e02e333ddbf2c1204f38b47e3e40657332a6f5
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@emnapi/wasi-threads@npm:1.2.1":
|
"@emnapi/wasi-threads@npm:1.2.0":
|
||||||
version: 1.2.1
|
version: 1.2.0
|
||||||
resolution: "@emnapi/wasi-threads@npm:1.2.1"
|
resolution: "@emnapi/wasi-threads@npm:1.2.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: "npm:^2.4.0"
|
tslib: "npm:^2.4.0"
|
||||||
checksum: 10c0/32fcfa81ab396533b2ec1f4082b1ff779a05d9c836bbbd3f4398405b0e6814c0d9503b7993130e37bc6941dbc1ded49f55e9700ae9ca4e803bab2b5bc5deb331
|
checksum: 10c0/1e3724b5814b06c14782fda87eee9b9aa68af01576c81ffeaefdf621ddb74386e419d5b3b1027b6a8172397729d95a92f814fc4b8d3c224376428faa07a6a01a
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -1563,7 +1563,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@prisma/client@npm:^7.6.0":
|
"@prisma/client@npm:7.6.0":
|
||||||
version: 7.6.0
|
version: 7.6.0
|
||||||
resolution: "@prisma/client@npm:7.6.0"
|
resolution: "@prisma/client@npm:7.6.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -1580,7 +1580,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@prisma/config@npm:7.6.0, @prisma/config@npm:^7.6.0":
|
"@prisma/config@npm:7.6.0":
|
||||||
version: 7.6.0
|
version: 7.6.0
|
||||||
resolution: "@prisma/config@npm:7.6.0"
|
resolution: "@prisma/config@npm:7.6.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -1872,9 +1872,9 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@sinclair/typebox@npm:^0.34.0":
|
"@sinclair/typebox@npm:^0.34.0":
|
||||||
version: 0.34.49
|
version: 0.34.48
|
||||||
resolution: "@sinclair/typebox@npm:0.34.49"
|
resolution: "@sinclair/typebox@npm:0.34.48"
|
||||||
checksum: 10c0/16b7d87f039a49b68c10bb4cdcae2ce5242b2472228851fd6483731616aba4ef977690aa517b230a8d20da8185bb416eb34e326f30568b3963c1cf26b05d1ad8
|
checksum: 10c0/e09f26d8ad471a07ee64004eea7c4ec185349a1f61c03e87e71ea33cbe98e97959940076c2e52968a955ffd4c215bf5ba7035e77079511aac7935f25e989e29d
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -2220,137 +2220,137 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin@npm:^8.57.2":
|
"@typescript-eslint/eslint-plugin@npm:^8.57.2":
|
||||||
version: 8.58.0
|
version: 8.57.2
|
||||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.58.0"
|
resolution: "@typescript-eslint/eslint-plugin@npm:8.57.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||||
"@typescript-eslint/scope-manager": "npm:8.58.0"
|
"@typescript-eslint/scope-manager": "npm:8.57.2"
|
||||||
"@typescript-eslint/type-utils": "npm:8.58.0"
|
"@typescript-eslint/type-utils": "npm:8.57.2"
|
||||||
"@typescript-eslint/utils": "npm:8.58.0"
|
"@typescript-eslint/utils": "npm:8.57.2"
|
||||||
"@typescript-eslint/visitor-keys": "npm:8.58.0"
|
"@typescript-eslint/visitor-keys": "npm:8.57.2"
|
||||||
ignore: "npm:^7.0.5"
|
ignore: "npm:^7.0.5"
|
||||||
natural-compare: "npm:^1.4.0"
|
natural-compare: "npm:^1.4.0"
|
||||||
ts-api-utils: "npm:^2.5.0"
|
ts-api-utils: "npm:^2.4.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@typescript-eslint/parser": ^8.58.0
|
"@typescript-eslint/parser": ^8.57.2
|
||||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||||
typescript: ">=4.8.4 <6.1.0"
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
checksum: 10c0/ac45c30f6ba9e188a01144708aa845e7ee8bb8a4d4f9aa6d2dce7784852d0821d42b031fee6832069935c3b885feff6d4014e30145b99693d25d7f563266a9f8
|
checksum: 10c0/92f3a45f6c2104cef5294bfba972c475b1d3fafb6070efa1178b38cb951e7dfbaf89eae50bfd95f4a476fe51783e218b115bd7cbc09fc9bc7c0ca6c5233861d2
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/parser@npm:^8.57.2":
|
"@typescript-eslint/parser@npm:^8.57.2":
|
||||||
version: 8.58.0
|
version: 8.57.2
|
||||||
resolution: "@typescript-eslint/parser@npm:8.58.0"
|
resolution: "@typescript-eslint/parser@npm:8.57.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/scope-manager": "npm:8.58.0"
|
"@typescript-eslint/scope-manager": "npm:8.57.2"
|
||||||
"@typescript-eslint/types": "npm:8.58.0"
|
"@typescript-eslint/types": "npm:8.57.2"
|
||||||
"@typescript-eslint/typescript-estree": "npm:8.58.0"
|
"@typescript-eslint/typescript-estree": "npm:8.57.2"
|
||||||
"@typescript-eslint/visitor-keys": "npm:8.58.0"
|
"@typescript-eslint/visitor-keys": "npm:8.57.2"
|
||||||
debug: "npm:^4.4.3"
|
debug: "npm:^4.4.3"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||||
typescript: ">=4.8.4 <6.1.0"
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
checksum: 10c0/56c7ec21675cec4730760bfa37c29e42e80b4d6444e2beca55fad9ef53731392270d142797482ea798405be0d7e28ec6c9c16a1ee2ee1c94f73d3bf0ed29763c
|
checksum: 10c0/afd8a30bd42ac56b212f3182d1b60e4556542eb22147b5b7a9a606d3c79ee35e596baf0bd7672d7e236472d246efc86e06265a46be26150ac12b05e4c45d16a6
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/project-service@npm:8.58.0":
|
"@typescript-eslint/project-service@npm:8.57.2":
|
||||||
version: 8.58.0
|
version: 8.57.2
|
||||||
resolution: "@typescript-eslint/project-service@npm:8.58.0"
|
resolution: "@typescript-eslint/project-service@npm:8.57.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/tsconfig-utils": "npm:^8.58.0"
|
"@typescript-eslint/tsconfig-utils": "npm:^8.57.2"
|
||||||
"@typescript-eslint/types": "npm:^8.58.0"
|
"@typescript-eslint/types": "npm:^8.57.2"
|
||||||
debug: "npm:^4.4.3"
|
debug: "npm:^4.4.3"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: ">=4.8.4 <6.1.0"
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
checksum: 10c0/e6d0cb2f7708ccb31a2ff9eb35817d4999c26e1f1cd3c607539e21d0c73a234daa77c73ee1163bc4e8b139252d619823c444759f1ddabdd138cab4885e9c9794
|
checksum: 10c0/f84e3165b0a214318d4bc119018b87c044170d7638945e84bd4cee2d752b62c1797ce722ca1161cd06f48512d0115ef75500e6c8fc01005ad4bb39fb48dd77bf
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/scope-manager@npm:8.58.0":
|
"@typescript-eslint/scope-manager@npm:8.57.2":
|
||||||
version: 8.58.0
|
version: 8.57.2
|
||||||
resolution: "@typescript-eslint/scope-manager@npm:8.58.0"
|
resolution: "@typescript-eslint/scope-manager@npm:8.57.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/types": "npm:8.58.0"
|
"@typescript-eslint/types": "npm:8.57.2"
|
||||||
"@typescript-eslint/visitor-keys": "npm:8.58.0"
|
"@typescript-eslint/visitor-keys": "npm:8.57.2"
|
||||||
checksum: 10c0/bd5c16780f22d62359af0f69909f38a15fa3c55e609124a7cd5c2a04322fe41e586d81066f3ad1dcc3c1eff24dbcb48b78d099626d611fbd680c20c005d48f1d
|
checksum: 10c0/532b1a97a5c2fce51400fa1a94e09615b4df84ce1f2d107206a3f3935074cada396a3e30f155582a698981832868e1afea1641ff779ad9456fdc94169b7def64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/tsconfig-utils@npm:8.58.0, @typescript-eslint/tsconfig-utils@npm:^8.58.0":
|
"@typescript-eslint/tsconfig-utils@npm:8.57.2, @typescript-eslint/tsconfig-utils@npm:^8.57.2":
|
||||||
version: 8.58.0
|
version: 8.57.2
|
||||||
resolution: "@typescript-eslint/tsconfig-utils@npm:8.58.0"
|
resolution: "@typescript-eslint/tsconfig-utils@npm:8.57.2"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: ">=4.8.4 <6.1.0"
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
checksum: 10c0/0a07fe1a28b2513e625882bc8d4c4e0c5a105cdbcb987beae12fc66dbe71dc9638013e4d1fa8ad10d828a2acd5e3fed987c189c00d41fed0e880009f99adf1b2
|
checksum: 10c0/199dad2d96efc88ce94f5f3e12e97205537bf7a7152e56ef1d84dfbe7bd1babebea9b9f396c01b6c447505a4eb02c1cbbd2c28828c587b51b41b15d017a11d2f
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/type-utils@npm:8.58.0":
|
"@typescript-eslint/type-utils@npm:8.57.2":
|
||||||
version: 8.58.0
|
version: 8.57.2
|
||||||
resolution: "@typescript-eslint/type-utils@npm:8.58.0"
|
resolution: "@typescript-eslint/type-utils@npm:8.57.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/types": "npm:8.58.0"
|
"@typescript-eslint/types": "npm:8.57.2"
|
||||||
"@typescript-eslint/typescript-estree": "npm:8.58.0"
|
"@typescript-eslint/typescript-estree": "npm:8.57.2"
|
||||||
"@typescript-eslint/utils": "npm:8.58.0"
|
"@typescript-eslint/utils": "npm:8.57.2"
|
||||||
debug: "npm:^4.4.3"
|
debug: "npm:^4.4.3"
|
||||||
ts-api-utils: "npm:^2.5.0"
|
ts-api-utils: "npm:^2.4.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||||
typescript: ">=4.8.4 <6.1.0"
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
checksum: 10c0/1223733d41f8463be92ef1ad048d546f9663152212b22dc968abbd9f8e4486bd4082e16baa51d2d281e0d4815563bc4b1ecf01684e2940b7897ba17aa26d1196
|
checksum: 10c0/9c479cd0e809d26b7da7b31e830520bc016aaf528bc10a8b8279374808cb76a27f1b4adc77c84156417dc70f6a9e8604f47717b555a27293da2b9b5cfda70411
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/types@npm:8.58.0, @typescript-eslint/types@npm:^8.58.0":
|
"@typescript-eslint/types@npm:8.57.2, @typescript-eslint/types@npm:^8.57.2":
|
||||||
version: 8.58.0
|
version: 8.57.2
|
||||||
resolution: "@typescript-eslint/types@npm:8.58.0"
|
resolution: "@typescript-eslint/types@npm:8.57.2"
|
||||||
checksum: 10c0/f2fe1321758a04591c20d77caba956ae76b77cff0b976a0224b37077d80b1ebd826874d15ec79c3a3b7d57ee5679e5d10756db1b082bde3d51addbd3a8431d38
|
checksum: 10c0/3cd87dd77d28b3ac2fed56a17909b0d11633628d4d733aa148dfd7af72e2cc3ec0e6114b72fac0ff538e8a47e907b4b10dab4095170ae1bd73719ef0b8eaf2e7
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree@npm:8.58.0":
|
"@typescript-eslint/typescript-estree@npm:8.57.2":
|
||||||
version: 8.58.0
|
version: 8.57.2
|
||||||
resolution: "@typescript-eslint/typescript-estree@npm:8.58.0"
|
resolution: "@typescript-eslint/typescript-estree@npm:8.57.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/project-service": "npm:8.58.0"
|
"@typescript-eslint/project-service": "npm:8.57.2"
|
||||||
"@typescript-eslint/tsconfig-utils": "npm:8.58.0"
|
"@typescript-eslint/tsconfig-utils": "npm:8.57.2"
|
||||||
"@typescript-eslint/types": "npm:8.58.0"
|
"@typescript-eslint/types": "npm:8.57.2"
|
||||||
"@typescript-eslint/visitor-keys": "npm:8.58.0"
|
"@typescript-eslint/visitor-keys": "npm:8.57.2"
|
||||||
debug: "npm:^4.4.3"
|
debug: "npm:^4.4.3"
|
||||||
minimatch: "npm:^10.2.2"
|
minimatch: "npm:^10.2.2"
|
||||||
semver: "npm:^7.7.3"
|
semver: "npm:^7.7.3"
|
||||||
tinyglobby: "npm:^0.2.15"
|
tinyglobby: "npm:^0.2.15"
|
||||||
ts-api-utils: "npm:^2.5.0"
|
ts-api-utils: "npm:^2.4.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: ">=4.8.4 <6.1.0"
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
checksum: 10c0/a8cb94cb765b27740a54f9b5378bd8f0dc49e301ceed99a0791dc9d1f61c2a54e3212f7ed9120c8c2df80104ad3117150cf5e7fe8a0b7eec3ed04969a79b103e
|
checksum: 10c0/2c5d143f0abbafd07a45f0b956aab5d6487b27f74fe93bee93e0a3f8edc8913f1522faf8d7d5215f3809a8d12f5729910ea522156552f2481b66e6d05ab311ae
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/utils@npm:8.58.0":
|
"@typescript-eslint/utils@npm:8.57.2":
|
||||||
version: 8.58.0
|
version: 8.57.2
|
||||||
resolution: "@typescript-eslint/utils@npm:8.58.0"
|
resolution: "@typescript-eslint/utils@npm:8.57.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@eslint-community/eslint-utils": "npm:^4.9.1"
|
"@eslint-community/eslint-utils": "npm:^4.9.1"
|
||||||
"@typescript-eslint/scope-manager": "npm:8.58.0"
|
"@typescript-eslint/scope-manager": "npm:8.57.2"
|
||||||
"@typescript-eslint/types": "npm:8.58.0"
|
"@typescript-eslint/types": "npm:8.57.2"
|
||||||
"@typescript-eslint/typescript-estree": "npm:8.58.0"
|
"@typescript-eslint/typescript-estree": "npm:8.57.2"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||||
typescript: ">=4.8.4 <6.1.0"
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
checksum: 10c0/457e01a6e6d954dbfe13c49ece3cf8a55e5d8cf19ea9ae7086c0e205d89e3cdbb91153062ab440d2e78ad3f077b174adc42bfb1b6fc24299020a0733e7f9c11c
|
checksum: 10c0/5771f3d4206004cc817a6556a472926b4c1c885dc448049c10ffab1d5aac7bd59450a391fb57ce8ef31a8367e9c8ddb3bc9370c4e83fc8b61f50fd5189390e8f
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/visitor-keys@npm:8.58.0":
|
"@typescript-eslint/visitor-keys@npm:8.57.2":
|
||||||
version: 8.58.0
|
version: 8.57.2
|
||||||
resolution: "@typescript-eslint/visitor-keys@npm:8.58.0"
|
resolution: "@typescript-eslint/visitor-keys@npm:8.57.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/types": "npm:8.58.0"
|
"@typescript-eslint/types": "npm:8.57.2"
|
||||||
eslint-visitor-keys: "npm:^5.0.0"
|
eslint-visitor-keys: "npm:^5.0.0"
|
||||||
checksum: 10c0/75f3c9c097a308cc6450822a0f81d44c8b79b524e99dd2c41ded347b12f148ab3bd459ce9cc6bd00f8f0725c5831baab6d2561596ead3394ab76dddbeb32cce1
|
checksum: 10c0/8ceb8c228bf97b3e4b343bf6e42a91998d2522f459eb6b53c6bfad4898a9df74295660893dee6b698bdbbda537e968bfc13a3c56fc341089ebfba13db766a574
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -2755,11 +2755,11 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"baseline-browser-mapping@npm:^2.9.0":
|
"baseline-browser-mapping@npm:^2.9.0":
|
||||||
version: 2.10.12
|
version: 2.10.11
|
||||||
resolution: "baseline-browser-mapping@npm:2.10.12"
|
resolution: "baseline-browser-mapping@npm:2.10.11"
|
||||||
bin:
|
bin:
|
||||||
baseline-browser-mapping: dist/cli.cjs
|
baseline-browser-mapping: dist/cli.cjs
|
||||||
checksum: 10c0/391d354240160546c8248317698b61f21f287cc6444766414c2d299a8880045e605ed97e8d8cd198a0b9dfaa4e73c2fa765bbef089474533a904733b1dc9a363
|
checksum: 10c0/6ae44b653c26de8ea7b75a109c0771c95c9676c32a11aff25f7c10b2ddeb864d2c387243a0ec083dffe3b76a7aacf2d777fa7e5a6df16e3a2624c1573e116285
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -2775,25 +2775,25 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"brace-expansion@npm:^1.1.7":
|
"brace-expansion@npm:^1.1.7":
|
||||||
version: 1.1.13
|
version: 1.1.12
|
||||||
resolution: "brace-expansion@npm:1.1.13"
|
resolution: "brace-expansion@npm:1.1.12"
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: "npm:^1.0.0"
|
balanced-match: "npm:^1.0.0"
|
||||||
concat-map: "npm:0.0.1"
|
concat-map: "npm:0.0.1"
|
||||||
checksum: 10c0/384c61bb329b6adfdcc0cbbdd108dc19fb5f3e84ae15a02a74f94c6c791b5a9b035aae73b2a51929a8a478e2f0f212a771eb6a8b5b514cccfb8d0c9f2ce8cbd8
|
checksum: 10c0/975fecac2bb7758c062c20d0b3b6288c7cc895219ee25f0a64a9de662dbac981ff0b6e89909c3897c1f84fa353113a721923afdec5f8b2350255b097f12b1f73
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"brace-expansion@npm:^2.0.2":
|
"brace-expansion@npm:^2.0.2":
|
||||||
version: 2.0.3
|
version: 2.0.2
|
||||||
resolution: "brace-expansion@npm:2.0.3"
|
resolution: "brace-expansion@npm:2.0.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: "npm:^1.0.0"
|
balanced-match: "npm:^1.0.0"
|
||||||
checksum: 10c0/468436c9b2fa6f9e64d0cff8784b21300677571a7196e258593e95e7c3db9973a80fbafdb0f01404d5d298a04dc666eae1fc3c9052e2edbb9f2510541deeddfe
|
checksum: 10c0/6d117a4c793488af86b83172deb6af143e94c17bc53b0b3cec259733923b4ca84679d506ac261f4ba3c7ed37c46018e2ff442f9ce453af8643ecd64f4a54e6cf
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"brace-expansion@npm:^5.0.5":
|
"brace-expansion@npm:^5.0.2":
|
||||||
version: 5.0.5
|
version: 5.0.5
|
||||||
resolution: "brace-expansion@npm:5.0.5"
|
resolution: "brace-expansion@npm:5.0.5"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -2907,9 +2907,9 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"caniuse-lite@npm:^1.0.30001759":
|
"caniuse-lite@npm:^1.0.30001759":
|
||||||
version: 1.0.30001782
|
version: 1.0.30001781
|
||||||
resolution: "caniuse-lite@npm:1.0.30001782"
|
resolution: "caniuse-lite@npm:1.0.30001781"
|
||||||
checksum: 10c0/f11685de4ce1f0bc16d385fc0a07b0877da0b14af8bf510cee6a3cdfe9da1602360e1f11320e92d4f5d63cd6bec8b43539de25ee78ff94bdb7ec0fa3cce5200c
|
checksum: 10c0/79e77d8759a55e90f0f5db96ab9e7925c7b2e3021f77852e647e45f64f7dc701954174188438e84b810824afc16d706c64a38f20f9c1ed9ac174b6362d33325f
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -3201,7 +3201,14 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"discord-api-types@npm:^0.38.1, discord-api-types@npm:^0.38.33, discord-api-types@npm:^0.38.40, discord-api-types@npm:^0.38.41":
|
"discord-api-types@npm:^0.38.1, discord-api-types@npm:^0.38.33, discord-api-types@npm:^0.38.40":
|
||||||
|
version: 0.38.42
|
||||||
|
resolution: "discord-api-types@npm:0.38.42"
|
||||||
|
checksum: 10c0/e902e50b7d10f788a7f993429fffce1fbb104b0ccd4ef1506c4c3680cb551b7562e1345d1bb4764e70a2a5325b1c092a4fad89e2dd58dab1cdeebac461b66e55
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"discord-api-types@npm:^0.38.41":
|
||||||
version: 0.38.43
|
version: 0.38.43
|
||||||
resolution: "discord-api-types@npm:0.38.43"
|
resolution: "discord-api-types@npm:0.38.43"
|
||||||
checksum: 10c0/8d617f63e415f0a238fddd8ae3cb9f88164b884ce321e4456f4d1c8329e5c8ff2132a2423c78d1fced773d845ece9570b586f49a383f095b0ca3734844cfcaf0
|
checksum: 10c0/8d617f63e415f0a238fddd8ae3cb9f88164b884ce321e4456f4d1c8329e5c8ff2132a2423c78d1fced773d845ece9570b586f49a383f095b0ca3734844cfcaf0
|
||||||
|
|
@ -3261,9 +3268,9 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"electron-to-chromium@npm:^1.5.263":
|
"electron-to-chromium@npm:^1.5.263":
|
||||||
version: 1.5.329
|
version: 1.5.326
|
||||||
resolution: "electron-to-chromium@npm:1.5.329"
|
resolution: "electron-to-chromium@npm:1.5.326"
|
||||||
checksum: 10c0/a275d7dd7ef26b98d304d37831684614b575d91d5186d3764e7c10114677ba84f4b9ee54a7ef326f63f2dbb2ca883582e3ef9925d9aee8562e1982fa42c94c43
|
checksum: 10c0/8a773ac36b36c5e62c3704a27adbe4d2a72961a8d46782c96e2515a13fbcc6f9e850b1e478482775ee3874ac4b42cd5c32870b2faf4c8e1c7569e172e310c314
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -4790,8 +4797,7 @@ __metadata:
|
||||||
"@discordjs/opus": "npm:^0.10.0"
|
"@discordjs/opus": "npm:^0.10.0"
|
||||||
"@discordjs/voice": "npm:^0.19.2"
|
"@discordjs/voice": "npm:^0.19.2"
|
||||||
"@prisma/adapter-pg": "npm:^7.6.0"
|
"@prisma/adapter-pg": "npm:^7.6.0"
|
||||||
"@prisma/client": "npm:^7.6.0"
|
"@prisma/client": "npm:7.6.0"
|
||||||
"@prisma/config": "npm:^7.6.0"
|
|
||||||
"@types/jest": "npm:^30.0.0"
|
"@types/jest": "npm:^30.0.0"
|
||||||
"@types/node": "npm:^25.5.0"
|
"@types/node": "npm:^25.5.0"
|
||||||
"@types/pg": "npm:^8.20.0"
|
"@types/pg": "npm:^8.20.0"
|
||||||
|
|
@ -4806,7 +4812,7 @@ __metadata:
|
||||||
pg: "npm:^8.20.0"
|
pg: "npm:^8.20.0"
|
||||||
prettier: "npm:^3.8.1"
|
prettier: "npm:^3.8.1"
|
||||||
prism-media: "npm:^1.3.5"
|
prism-media: "npm:^1.3.5"
|
||||||
prisma: "npm:^7.6.0"
|
prisma: "npm:7.6.0"
|
||||||
sharp: "npm:^0.34.5"
|
sharp: "npm:^0.34.5"
|
||||||
ts-jest: "npm:^29.4.6"
|
ts-jest: "npm:^29.4.6"
|
||||||
tsx: "npm:^4.21.0"
|
tsx: "npm:^4.21.0"
|
||||||
|
|
@ -4906,9 +4912,9 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1":
|
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1":
|
||||||
version: 11.3.0
|
version: 11.2.7
|
||||||
resolution: "lru-cache@npm:11.3.0"
|
resolution: "lru-cache@npm:11.2.7"
|
||||||
checksum: 10c0/f50d32064c07b17390e7bf89fa09fcf05c19018d8cb03359a04dad3e16e4213812958af32eb0e29a92239a1f13631b58c26c007367f99946defcf4e495c1c123
|
checksum: 10c0/549cdb59488baa617135fc12159cafb1a97f91079f35093bb3bcad72e849fc64ace636d244212c181dfdf1a99bbfa90757ff303f98561958ee4d0f885d9bd5f7
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -5011,11 +5017,11 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"minimatch@npm:^10.2.2, minimatch@npm:^10.2.4":
|
"minimatch@npm:^10.2.2, minimatch@npm:^10.2.4":
|
||||||
version: 10.2.5
|
version: 10.2.4
|
||||||
resolution: "minimatch@npm:10.2.5"
|
resolution: "minimatch@npm:10.2.4"
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: "npm:^5.0.5"
|
brace-expansion: "npm:^5.0.2"
|
||||||
checksum: 10c0/6bb058bd6324104b9ec2f763476a35386d05079c1f5fe4fbf1f324a25237cd4534d6813ecd71f48208f4e635c1221899bef94c3c89f7df55698fe373aaae20fd
|
checksum: 10c0/35f3dfb7b99b51efd46afd378486889f590e7efb10e0f6a10ba6800428cf65c9a8dedb74427d0570b318d749b543dc4e85f06d46d2858bc8cac7e1eb49a95945
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -5734,7 +5740,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"prisma@npm:^7.6.0":
|
"prisma@npm:7.6.0":
|
||||||
version: 7.6.0
|
version: 7.6.0
|
||||||
resolution: "prisma@npm:7.6.0"
|
resolution: "prisma@npm:7.6.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -6360,7 +6366,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"ts-api-utils@npm:^2.5.0":
|
"ts-api-utils@npm:^2.4.0":
|
||||||
version: 2.5.0
|
version: 2.5.0
|
||||||
resolution: "ts-api-utils@npm:2.5.0"
|
resolution: "ts-api-utils@npm:2.5.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue