Compare commits
11 Commits
1432e0d090
...
c864be267f
| Author | SHA1 | Date |
|---|---|---|
|
|
c864be267f | |
|
|
41a97c474e | |
|
|
e4bcf53308 | |
|
|
2a8b6d41dc | |
|
|
c10822ea87 | |
|
|
57d38a4e09 | |
|
|
57805644fb | |
|
|
aad37ef855 | |
|
|
c1d18d7d8f | |
|
|
1f91bfb9bf | |
|
|
e43af4f944 |
|
|
@ -6,6 +6,5 @@ DISCORD_CLIENT_ID=your_client_id_here
|
||||||
# User/pass from docker-compose.yml
|
# User/pass from docker-compose.yml
|
||||||
DATABASE_URL="postgresql://kord:password@localhost:5432/kord_db?schema=public"
|
DATABASE_URL="postgresql://kord:password@localhost:5432/kord_db?schema=public"
|
||||||
|
|
||||||
# Redis Configuration
|
## NOTE
|
||||||
REDIS_HOST="localhost"
|
## This project does not use Redis.
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
@ -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
|
RUN corepack enable && yarn install --immutable
|
||||||
|
|
||||||
# Generate Prisma Client
|
# Generate Prisma Client
|
||||||
COPY prisma ./prisma/
|
COPY prisma ./prisma/
|
||||||
|
|
@ -17,9 +17,8 @@ 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
|
RUN corepack enable && yarn install --immutable --production
|
||||||
|
|
||||||
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 ["yarn", "node", "dist/index.js"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# 프로젝트 구조 (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)입니다.
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
# 데이터베이스 테이블 구조
|
||||||
|
|
||||||
|
이 문서는 [Prisma 스키마](../prisma/schema.prisma)를 기준으로 한 **PostgreSQL** 테이블 구조 요약입니다.
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| DB | PostgreSQL |
|
||||||
|
| ORM | Prisma (`prisma-client-js`) |
|
||||||
|
| 연결 | `DATABASE_URL` 환경 변수 |
|
||||||
|
|
||||||
|
## 열거형 (Enum)
|
||||||
|
|
||||||
|
### `SubscriptionTier`
|
||||||
|
|
||||||
|
구독 단계.
|
||||||
|
|
||||||
|
| 값 | 설명 |
|
||||||
|
|----|------|
|
||||||
|
| `FREE` | 기본 |
|
||||||
|
| `STANDARD` | 스탠다드 |
|
||||||
|
| `PRO` | 프로 |
|
||||||
|
| `PREMIUM` | 프리미엄 |
|
||||||
|
|
||||||
|
### `DeleteCondition`
|
||||||
|
|
||||||
|
임시 음성 채널 삭제 조건.
|
||||||
|
|
||||||
|
| 값 | 설명 |
|
||||||
|
|----|------|
|
||||||
|
| `OWNER_LEAVE` | 소유자 퇴장 시 |
|
||||||
|
| `EMPTY` | 비었을 때 (기본) |
|
||||||
|
|
||||||
|
### `EventStatus`
|
||||||
|
|
||||||
|
길드 이벤트 상태.
|
||||||
|
|
||||||
|
| 값 | 설명 |
|
||||||
|
|----|------|
|
||||||
|
| `SCHEDULED` | 예정 (기본) |
|
||||||
|
| `CANCELLED` | 취소 |
|
||||||
|
| `COMPLETED` | 완료 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테이블 목록
|
||||||
|
|
||||||
|
### `GuildConfig`
|
||||||
|
|
||||||
|
디스코드 길드별 봇 설정.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `guildId` | `String` | PK |
|
||||||
|
| `prefix` | `String` | 기본 `!` |
|
||||||
|
| `mimicEnabled` | `Boolean` | 기본 `false` |
|
||||||
|
| `bigEmojiEnabled` | `Boolean` | 기본 `false` |
|
||||||
|
| `locale` | `String?` | nullable |
|
||||||
|
| `createdAt` | `DateTime` | 생성 시각 |
|
||||||
|
| `updatedAt` | `DateTime` | 자동 갱신 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `InviteRole`
|
||||||
|
|
||||||
|
길드·초대 코드별 역할 매핑.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `id` | `String` | PK, UUID |
|
||||||
|
| `guildId` | `String` | |
|
||||||
|
| `inviteCode` | `String` | |
|
||||||
|
| `roleId` | `String` | |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**유니크:** `(guildId, inviteCode)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UserSubscription`
|
||||||
|
|
||||||
|
사용자 구독 정보.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `userId` | `String` | PK |
|
||||||
|
| `tier` | `SubscriptionTier` | 기본 `FREE` |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**관계:** `GuildOwnership[]` (1:N)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GuildOwnership`
|
||||||
|
|
||||||
|
구독 사용자가 소유하는 길드.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `guildId` | `String` | PK |
|
||||||
|
| `ownerId` | `String` | FK → `UserSubscription.userId`, `ON DELETE CASCADE` |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**인덱스:** `ownerId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `VoiceGenerator`
|
||||||
|
|
||||||
|
음성 채널 생성기(부모 채널) 설정.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `channelId` | `String` | PK |
|
||||||
|
| `guildId` | `String` | |
|
||||||
|
| `categoryId` | `String?` | |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**인덱스:** `guildId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `TempVoiceChannel`
|
||||||
|
|
||||||
|
생성된 임시 음성 채널.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `channelId` | `String` | PK |
|
||||||
|
| `guildId` | `String` | |
|
||||||
|
| `ownerId` | `String` | |
|
||||||
|
| `deleteWhen` | `DeleteCondition` | 기본 `EMPTY` |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**인덱스:** `guildId`, `ownerId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UserVoiceProfile`
|
||||||
|
|
||||||
|
길드별 사용자 음성 프로필(표시 이름·인원 제한 등).
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `userId` | `String` | 복합 PK |
|
||||||
|
| `guildId` | `String` | 복합 PK |
|
||||||
|
| `customName` | `String?` | |
|
||||||
|
| `userLimit` | `Int?` | |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**PK:** `(userId, guildId)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `UserLocale`
|
||||||
|
|
||||||
|
사용자별 로케일.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `userId` | `String` | PK |
|
||||||
|
| `locale` | `String` | |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `AuditChannel`
|
||||||
|
|
||||||
|
감사 로그 전달 채널·비활성 카테고리.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `guildId` | `String` | PK |
|
||||||
|
| `channelId` | `String` | |
|
||||||
|
| `disabledCategories` | `String[]` | 기본 `["BOOT", "SYSTEM"]` |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `VoiceGuildConfig`
|
||||||
|
|
||||||
|
길드 단위 음성(임시 채널) 기본 설정.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `guildId` | `String` | PK |
|
||||||
|
| `defaultNameTemplate` | `String` | 기본 `{{username}}'s Room` |
|
||||||
|
| `defaultUserLimit` | `Int` | 기본 `0` |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GuildEvent`
|
||||||
|
|
||||||
|
길드 일정/이벤트.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 / 기본값 |
|
||||||
|
|------|------|----------------|
|
||||||
|
| `id` | `String` | PK, UUID |
|
||||||
|
| `guildId` | `String` | |
|
||||||
|
| `title` | `String` | |
|
||||||
|
| `description` | `String?` | |
|
||||||
|
| `startsAt` | `DateTime` | |
|
||||||
|
| `timezone` | `String` | 기본 `Asia/Seoul` |
|
||||||
|
| `status` | `EventStatus` | 기본 `SCHEDULED` |
|
||||||
|
| `announcementChannelId` | `String?` | |
|
||||||
|
| `createdByUserId` | `String` | |
|
||||||
|
| `reminderEnabled` | `Boolean` | 기본 `true` |
|
||||||
|
| `reminderOffsets` | `Int[]` | 기본 `[]` |
|
||||||
|
| `sentReminderOffsets` | `Int[]` | 기본 `[]` |
|
||||||
|
| `remindedOneHour` | `Boolean` | 기본 `false` |
|
||||||
|
| `remindedTenMinutes` | `Boolean` | 기본 `false` |
|
||||||
|
| `startedAnnounced` | `Boolean` | 기본 `false` |
|
||||||
|
| `announcedAt` | `DateTime?` | |
|
||||||
|
| `createdAt` | `DateTime` | |
|
||||||
|
| `updatedAt` | `DateTime` | |
|
||||||
|
|
||||||
|
**인덱스:** `(guildId, startsAt)`, `(guildId, status)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관계 요약
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
UserSubscription ||--o{ GuildOwnership : "userId"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **UserSubscription** 1 — N **GuildOwnership** (`ownerId` → `userId`, 길드 삭제 시 소유권 행 CASCADE 삭제)
|
||||||
|
|
||||||
|
그 외 테이블은 Prisma 모델상 **명시적 `relation` 블록**이 없으며, `guildId` / `userId` / `channelId` 등이 애플리케이션 레벨에서 Discord ID로 연결됩니다.
|
||||||
|
|
||||||
|
## 스키마 변경 시
|
||||||
|
|
||||||
|
실제 DDL은 `prisma/migrations/` 아래 마이그레이션 SQL과 동기화됩니다. 구조를 바꾼 뒤에는 이 문서와 [schema.prisma](../prisma/schema.prisma)를 함께 맞추는 것이 좋습니다.
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
# Kord Documentation Index
|
# Kord Documentation Index
|
||||||
|
|
||||||
이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다.
|
이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다.
|
||||||
|
|
||||||
|
## 아키텍처 및 시스템 (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)
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,13 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
|
||||||
|
|
||||||
## 1. 개요 (Overview)
|
## 1. 개요 (Overview)
|
||||||
|
|
||||||
**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)와 Redis를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다.
|
**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다.
|
||||||
|
|
||||||
## 2. 요구사항 (Requirements)
|
## 2. 요구사항 (Requirements)
|
||||||
|
|
||||||
- **Runtime**: Node.js v20 이상
|
- **Runtime**: Node.js v20 이상
|
||||||
- **Package Manager**: Yarn v4 (Berry)
|
- **Package Manager**: Yarn v4 (Berry)
|
||||||
- **Database**: PostgreSQL (Prisma 사용)
|
- **Database**: PostgreSQL (Prisma 사용)
|
||||||
- **Cache**: Redis (다중 인스턴스 동기화 및 캐싱)
|
|
||||||
- **Discord**: Bot Token 및 Client ID (Slash Command 등록용)
|
- **Discord**: Bot Token 및 Client ID (Slash Command 등록용)
|
||||||
|
|
||||||
## 3. 테스트 방법 (Test Methods)
|
## 3. 테스트 방법 (Test Methods)
|
||||||
|
|
@ -59,7 +58,7 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
|
||||||
|
|
||||||
1. **빌드**: `yarn build`
|
1. **빌드**: `yarn build`
|
||||||
2. **실행**: `yarn start`
|
2. **실행**: `yarn start`
|
||||||
3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB, Redis)을 실행할 수 있습니다.
|
3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB)을 실행할 수 있습니다.
|
||||||
|
|
||||||
## 5. 기능 목록 (Feature List)
|
## 5. 기능 목록 (Feature List)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
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
|
||||||
|
|
@ -14,15 +23,5 @@ services:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: kord-redis
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,7 +5,8 @@
|
||||||
"@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",
|
||||||
|
|
@ -24,7 +25,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"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- 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,4 +1,4 @@
|
||||||
CREATE TABLE "FishingProfile" (
|
CREATE TABLE "FishingProfile" (
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
"guildId" TEXT NOT NULL,
|
"guildId" TEXT NOT NULL,
|
||||||
"totalCastCount" INTEGER NOT NULL DEFAULT 0,
|
"totalCastCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
CREATE TABLE "FishingCollectionEntry" (
|
CREATE TABLE "FishingCollectionEntry" (
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
"guildId" TEXT NOT NULL,
|
"guildId" TEXT NOT NULL,
|
||||||
"fishId" TEXT NOT NULL,
|
"fishId" TEXT NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["driverAdapters"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
|
|
@ -140,47 +141,73 @@ enum EventStatus {
|
||||||
COMPLETED
|
COMPLETED
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Mini Game System ───────────────────────────────────────────────────────
|
/// 길드별 유료 기능(특성) 플래그. 행이 없거나 해당 컬럼이 false면 미결제로 간주합니다.
|
||||||
|
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
|
||||||
|
|
||||||
@@unique([guildId, gameKey])
|
|
||||||
@@index([guildId])
|
@@index([guildId])
|
||||||
|
@@unique([guildId, gameKey])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 재련 - 유저 상태
|
|
||||||
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)
|
||||||
lastBattleReset DateTime @default(now())
|
isDisabled Boolean @default(false)
|
||||||
isDisabled Boolean @default(false)
|
lastCheckIn DateTime?
|
||||||
lastCheckIn DateTime?
|
lastBattleReset DateTime @default(now())
|
||||||
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 ActivityLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
guildId String
|
||||||
|
hour Int
|
||||||
|
dayOfWeek Int
|
||||||
|
count Int @default(0)
|
||||||
|
weekStart DateTime
|
||||||
|
|
||||||
|
@@index([guildId, weekStart])
|
||||||
|
@@unique([guildId, hour, dayOfWeek, weekStart])
|
||||||
|
}
|
||||||
|
|
||||||
|
model FeverState {
|
||||||
|
guildId String @id
|
||||||
|
isActive Boolean @default(false)
|
||||||
|
peakHour Int?
|
||||||
|
bonusRate Float @default(0.1)
|
||||||
|
expiresAt DateTime?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model RefinementLevelConfig {
|
model RefinementLevelConfig {
|
||||||
level Int @id
|
level Int @id
|
||||||
successRate Float
|
successRate Float
|
||||||
|
|
@ -191,45 +218,19 @@ 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
|
||||||
description String?
|
description String?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서버 활동 추이 (시간대별 메시지 수)
|
|
||||||
model ActivityLog {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
guildId String
|
|
||||||
hour Int
|
|
||||||
dayOfWeek Int
|
|
||||||
count Int @default(0)
|
|
||||||
weekStart DateTime
|
|
||||||
|
|
||||||
@@unique([guildId, hour, dayOfWeek, weekStart])
|
|
||||||
@@index([guildId, weekStart])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 피버 상태
|
|
||||||
model FeverState {
|
|
||||||
guildId String @id
|
|
||||||
isActive Boolean @default(false)
|
|
||||||
peakHour Int?
|
|
||||||
bonusRate Float @default(0.1)
|
|
||||||
expiresAt DateTime?
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model FishingProfile {
|
model FishingProfile {
|
||||||
userId String
|
userId String
|
||||||
guildId String
|
guildId String
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,52 @@
|
||||||
import Redis from 'ioredis';
|
type CacheEntry = { value: string; expiresAtMs?: number };
|
||||||
import { env } from '../config/env';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
|
|
||||||
export const redis = new Redis({
|
const nowMs = () => Date.now();
|
||||||
host: env.REDIS_HOST,
|
|
||||||
port: env.REDIS_PORT,
|
|
||||||
lazyConnect: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
redis.on('error', (err) => {
|
class LocalCache {
|
||||||
logger.error('Redis Error:', err);
|
private store = new Map<string, CacheEntry>();
|
||||||
});
|
|
||||||
|
|
||||||
export const connectRedis = async () => {
|
private isExpired(entry: CacheEntry | undefined) {
|
||||||
try {
|
return entry?.expiresAtMs !== undefined && entry.expiresAtMs <= nowMs();
|
||||||
await redis.connect();
|
|
||||||
logger.info('Connected to Redis successfully.');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to connect to Redis:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getEntry(key: string): CacheEntry | undefined {
|
||||||
|
const entry = this.store.get(key);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (this.isExpired(entry)) {
|
||||||
|
this.store.delete(key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
const entry = this.getEntry(key);
|
||||||
|
return entry ? entry.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string, opts?: { exSeconds?: number; nx?: boolean }): Promise<boolean> {
|
||||||
|
const existing = this.getEntry(key);
|
||||||
|
if (opts?.nx && existing) return false;
|
||||||
|
|
||||||
|
const expiresAtMs = opts?.exSeconds !== undefined ? nowMs() + opts.exSeconds * 1000 : undefined;
|
||||||
|
this.store.set(key, { value, expiresAtMs });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<boolean> {
|
||||||
|
const existed = this.getEntry(key) !== undefined;
|
||||||
|
this.store.delete(key);
|
||||||
|
return existed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cache = new LocalCache();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort local process lock.
|
||||||
|
* - Returns true only once per key during ttl window.
|
||||||
|
* - Single process only (no cross-instance coordination).
|
||||||
|
*/
|
||||||
|
export const tryAcquireLock = async (key: string, ttlSeconds: number): Promise<boolean> => {
|
||||||
|
return cache.set(`lock:${key}`, '1', { exSeconds: ttlSeconds, nx: true });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import { loadCommands } from '../handlers/CommandLoader';
|
||||||
import { loadEvents } from '../handlers/EventLoader';
|
import { loadEvents } from '../handlers/EventLoader';
|
||||||
import { handleGlobalExceptions } from '../utils/errorHandler';
|
import { handleGlobalExceptions } from '../utils/errorHandler';
|
||||||
import { connectDB } from '../database';
|
import { connectDB } from '../database';
|
||||||
import { connectRedis } from '../cache';
|
|
||||||
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();
|
||||||
|
|
@ -29,7 +27,6 @@ export class KordClient extends Client {
|
||||||
|
|
||||||
// Connect to external services
|
// Connect to external services
|
||||||
await connectDB();
|
await connectDB();
|
||||||
await connectRedis();
|
|
||||||
|
|
||||||
// Load Handlers
|
// Load Handlers
|
||||||
await loadCommands(this);
|
await loadCommands(this);
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ export const env = {
|
||||||
DISCORD_TOKEN: process.env.DISCORD_TOKEN || '',
|
DISCORD_TOKEN: process.env.DISCORD_TOKEN || '',
|
||||||
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID || '',
|
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID || '',
|
||||||
DATABASE_URL: process.env.DATABASE_URL || '',
|
DATABASE_URL: process.env.DATABASE_URL || '',
|
||||||
REDIS_HOST: process.env.REDIS_HOST || 'localhost',
|
|
||||||
REDIS_PORT: parseInt(process.env.REDIS_PORT || '6379', 10),
|
|
||||||
VOICE_WAITING_ROOM_ID: process.env.VOICE_WAITING_ROOM_ID || '',
|
VOICE_WAITING_ROOM_ID: process.env.VOICE_WAITING_ROOM_ID || '',
|
||||||
VOICE_CATEGORY_ID: process.env.VOICE_CATEGORY_ID || '',
|
VOICE_CATEGORY_ID: process.env.VOICE_CATEGORY_ID || '',
|
||||||
INSTANCE_ID: generateInstanceId(),
|
INSTANCE_ID: generateInstanceId(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
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,5 +1,4 @@
|
||||||
import { Events } from 'discord.js';
|
import { Events } from 'discord.js';
|
||||||
import { createHash } from 'crypto';
|
|
||||||
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 { InviteService } from '../services/InviteService';
|
||||||
|
|
@ -7,7 +6,7 @@ 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 { redis } from '../cache';
|
import { tryAcquireLock } from '../cache';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -21,19 +20,16 @@ export default {
|
||||||
EventService.startReminderLoop(client);
|
EventService.startReminderLoop(client);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON());
|
const lockKey = 'commands:sync:lock';
|
||||||
const commandsHash = createHash('sha256')
|
// EX 300 = 5 minutes lock. Only one instance needs to do this per boot cycle.
|
||||||
.update(JSON.stringify(commandsData))
|
const acquired = await tryAcquireLock(lockKey, 300);
|
||||||
.digest('hex');
|
|
||||||
const lockKey = `commands:sync:lock:${commandsHash}`;
|
|
||||||
// Lock per command definition set so updated commands can still sync on the next boot.
|
|
||||||
const acquired = await redis.set(lockKey, '1', 'EX', 300, 'NX');
|
|
||||||
|
|
||||||
if (acquired) {
|
if (acquired) {
|
||||||
|
const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON());
|
||||||
await client.application?.commands.set(commandsData);
|
await client.application?.commands.set(commandsData);
|
||||||
logger.info(`Successfully registered ${commandsData.length} global application commands. hash=${commandsHash}`);
|
logger.info(`Successfully registered ${commandsData.length} global application commands.`);
|
||||||
} else {
|
} else {
|
||||||
logger.info(`Global commands registration skipped for hash=${commandsHash} (already handled by another instance).`);
|
logger.info('Global commands registration skipped (already handled by another instance).');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to register global commands', e);
|
logger.error('Failed to register global commands', e);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
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 as any).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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
/**
|
||||||
|
* `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();
|
||||||
|
|
@ -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 =>
|
const dueOffsets = event.reminderOffsets.filter((offset: number) =>
|
||||||
offset > 0 &&
|
offset > 0 &&
|
||||||
!event.sentReminderOffsets.includes(offset) &&
|
!event.sentReminderOffsets.includes(offset) &&
|
||||||
diff <= offset * 60 * 1000 &&
|
diff <= offset * 60 * 1000 &&
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Client, Guild, Invite, GuildMember } from 'discord.js';
|
import { Client, Guild, Invite, GuildMember } from 'discord.js';
|
||||||
import { redis } from '../cache';
|
import { cache } from '../cache';
|
||||||
import { prisma } from '../database';
|
import { prisma } from '../database';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ export class InviteService {
|
||||||
code: inv.code,
|
code: inv.code,
|
||||||
uses: inv.uses || 0
|
uses: inv.uses || 0
|
||||||
}));
|
}));
|
||||||
await redis.set(`invites:${guild.id}`, JSON.stringify(inviteData));
|
await cache.set(`invites:${guild.id}`, JSON.stringify(inviteData));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error);
|
logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error);
|
||||||
}
|
}
|
||||||
|
|
@ -41,7 +41,7 @@ export class InviteService {
|
||||||
try {
|
try {
|
||||||
// Fetch current active invites
|
// Fetch current active invites
|
||||||
const newInvites = await guild.invites.fetch();
|
const newInvites = await guild.invites.fetch();
|
||||||
const cachedData = await redis.get(`invites:${guild.id}`);
|
const cachedData = await cache.get(`invites:${guild.id}`);
|
||||||
|
|
||||||
let usedInvite: Invite | undefined;
|
let usedInvite: Invite | undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => g.channelId);
|
return generators.map((g: { channelId: string }) => g.channelId);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -103,7 +103,9 @@ 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) => g.categoryId).filter((id): id is string => id !== null);
|
return generators
|
||||||
|
.map((g: { categoryId: string | null }) => g.categoryId)
|
||||||
|
.filter((id: string | null): id is string => id !== null);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -120,7 +122,7 @@ const FEATURE_DEFINITIONS: FeatureDefinition[] = [
|
||||||
scope: 'hierarchy',
|
scope: 'hierarchy',
|
||||||
resolveTargetRoleIds: async (guildId) => {
|
resolveTargetRoleIds: async (guildId) => {
|
||||||
const inviteRoles = await prisma.inviteRole.findMany({ where: { guildId } });
|
const inviteRoles = await prisma.inviteRole.findMany({ where: { guildId } });
|
||||||
return inviteRoles.map((ir) => ir.roleId);
|
return inviteRoles.map((ir: { roleId: string }) => ir.roleId);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
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 { redis } from '../cache';
|
import { tryAcquireLock } from '../cache';
|
||||||
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';
|
||||||
|
|
@ -10,8 +10,7 @@ import { auditLogService } from './AuditLogService';
|
||||||
export class VoiceService {
|
export class VoiceService {
|
||||||
public static async syncChannels(client: Client) {
|
public static async syncChannels(client: Client) {
|
||||||
const lockKey = 'voice:sync:lock';
|
const lockKey = 'voice:sync:lock';
|
||||||
// NX = only set if not exists, EX = expire in 60s
|
const acquired = await tryAcquireLock(lockKey, 60);
|
||||||
const acquired = await redis.set(lockKey, '1', 'EX', 60, 'NX');
|
|
||||||
if (!acquired) {
|
if (!acquired) {
|
||||||
logger.info('VoiceService: Another instance is already syncing channels. Skipping.');
|
logger.info('VoiceService: Another instance is already syncing channels. Skipping.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -20,7 +19,7 @@ export class VoiceService {
|
||||||
try {
|
try {
|
||||||
logger.info('VoiceService: Starting channel synchronization...');
|
logger.info('VoiceService: Starting channel synchronization...');
|
||||||
const channels = await prisma.tempVoiceChannel.findMany();
|
const channels = await prisma.tempVoiceChannel.findMany();
|
||||||
|
|
||||||
for (const temp of channels) {
|
for (const temp of channels) {
|
||||||
try {
|
try {
|
||||||
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);
|
||||||
|
|
@ -55,9 +54,8 @@ export class VoiceService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.info('VoiceService: Channel synchronization complete.');
|
logger.info('VoiceService: Channel synchronization complete.');
|
||||||
} finally {
|
} catch (error) {
|
||||||
// Free the lock just in case, though EX ensures it doesn't hang forever
|
logger.error('VoiceService: Failed during channel synchronization', error);
|
||||||
await redis.del(lockKey).catch(() => {});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
|
public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { TextChannel, WebhookClient } from 'discord.js';
|
import { TextChannel, WebhookClient } from 'discord.js';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { redis } from '../cache';
|
import { cache } from '../cache';
|
||||||
|
|
||||||
export class WebhookService {
|
export class WebhookService {
|
||||||
private static readonly MAX_WEBHOOKS = 10;
|
private static readonly MAX_WEBHOOKS = 10;
|
||||||
|
|
@ -9,7 +9,7 @@ export class WebhookService {
|
||||||
public static async getWebhookClient(channel: TextChannel): Promise<WebhookClient | null> {
|
public static async getWebhookClient(channel: TextChannel): Promise<WebhookClient | null> {
|
||||||
try {
|
try {
|
||||||
// 1. Check cache
|
// 1. Check cache
|
||||||
const cachedData = await redis.get(`webhook:${channel.id}`);
|
const cachedData = await cache.get(`webhook:${channel.id}`);
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
const { id, token } = JSON.parse(cachedData);
|
const { id, token } = JSON.parse(cachedData);
|
||||||
return new WebhookClient({ id, token });
|
return new WebhookClient({ id, token });
|
||||||
|
|
@ -40,14 +40,11 @@ export class WebhookService {
|
||||||
logger.info(`Created new webhook for channel ${channel.id}`);
|
logger.info(`Created new webhook for channel ${channel.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Save to Redis Cache (expire in 1 day to ensure token freshness)
|
// 3. Save to cache (expire in 1 day to ensure token freshness)
|
||||||
if (kordWebhook.token) {
|
if (kordWebhook.token) {
|
||||||
await redis.set(
|
await cache.set(`webhook:${channel.id}`, JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }), {
|
||||||
`webhook:${channel.id}`,
|
exSeconds: 86400,
|
||||||
JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }),
|
});
|
||||||
'EX',
|
|
||||||
86400
|
|
||||||
);
|
|
||||||
return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token });
|
return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
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.1
|
version: 1.9.2
|
||||||
resolution: "@emnapi/core@npm:1.9.1"
|
resolution: "@emnapi/core@npm:1.9.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@emnapi/wasi-threads": "npm:1.2.0"
|
"@emnapi/wasi-threads": "npm:1.2.1"
|
||||||
tslib: "npm:^2.4.0"
|
tslib: "npm:^2.4.0"
|
||||||
checksum: 10c0/00e7a99a2bc3ad908ca8272ba861a934da87dffa8797a41316c4a3b571a1e4d2743e2fa14b1a0f131fa4a3c2018ddb601cd2a8cb7f574fa940af696df3c2fe8d
|
checksum: 10c0/5500393f953951bad0768fafaa9191f2d938956b20c6d6a79e5ab696a613a25ce6ad23422bc18e86e6ce8deb147619d8d0d7d413a69f84adc01a6633cc353cd9
|
||||||
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.1
|
version: 1.9.2
|
||||||
resolution: "@emnapi/runtime@npm:1.9.1"
|
resolution: "@emnapi/runtime@npm:1.9.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: "npm:^2.4.0"
|
tslib: "npm:^2.4.0"
|
||||||
checksum: 10c0/750edca117e0363ab2de10622f8ee60e57d8690c2f29c49704813da5cd627c641798d7f3cb0d953c62fdc71688e02e333ddbf2c1204f38b47e3e40657332a6f5
|
checksum: 10c0/61c3a59e0c36784558b8d58eb02bd04815aa5fb0dbfbaf84d1b3050a78aa0cc63ea129ae806bd1e48062bfeb7fc36eb0e5431740d62f64ea51bdf426404b8caa
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@emnapi/wasi-threads@npm:1.2.0":
|
"@emnapi/wasi-threads@npm:1.2.1":
|
||||||
version: 1.2.0
|
version: 1.2.1
|
||||||
resolution: "@emnapi/wasi-threads@npm:1.2.0"
|
resolution: "@emnapi/wasi-threads@npm:1.2.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: "npm:^2.4.0"
|
tslib: "npm:^2.4.0"
|
||||||
checksum: 10c0/1e3724b5814b06c14782fda87eee9b9aa68af01576c81ffeaefdf621ddb74386e419d5b3b1027b6a8172397729d95a92f814fc4b8d3c224376428faa07a6a01a
|
checksum: 10c0/32fcfa81ab396533b2ec1f4082b1ff779a05d9c836bbbd3f4398405b0e6814c0d9503b7993130e37bc6941dbc1ded49f55e9700ae9ca4e803bab2b5bc5deb331
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -1570,7 +1570,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:
|
||||||
|
|
@ -1587,7 +1587,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:
|
||||||
|
|
@ -1879,9 +1879,9 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@sinclair/typebox@npm:^0.34.0":
|
"@sinclair/typebox@npm:^0.34.0":
|
||||||
version: 0.34.48
|
version: 0.34.49
|
||||||
resolution: "@sinclair/typebox@npm:0.34.48"
|
resolution: "@sinclair/typebox@npm:0.34.49"
|
||||||
checksum: 10c0/e09f26d8ad471a07ee64004eea7c4ec185349a1f61c03e87e71ea33cbe98e97959940076c2e52968a955ffd4c215bf5ba7035e77079511aac7935f25e989e29d
|
checksum: 10c0/16b7d87f039a49b68c10bb4cdcae2ce5242b2472228851fd6483731616aba4ef977690aa517b230a8d20da8185bb416eb34e326f30568b3963c1cf26b05d1ad8
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -2227,137 +2227,137 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin@npm:^8.57.2":
|
"@typescript-eslint/eslint-plugin@npm:^8.57.2":
|
||||||
version: 8.57.2
|
version: 8.58.0
|
||||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.57.2"
|
resolution: "@typescript-eslint/eslint-plugin@npm:8.58.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||||
"@typescript-eslint/scope-manager": "npm:8.57.2"
|
"@typescript-eslint/scope-manager": "npm:8.58.0"
|
||||||
"@typescript-eslint/type-utils": "npm:8.57.2"
|
"@typescript-eslint/type-utils": "npm:8.58.0"
|
||||||
"@typescript-eslint/utils": "npm:8.57.2"
|
"@typescript-eslint/utils": "npm:8.58.0"
|
||||||
"@typescript-eslint/visitor-keys": "npm:8.57.2"
|
"@typescript-eslint/visitor-keys": "npm:8.58.0"
|
||||||
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.4.0"
|
ts-api-utils: "npm:^2.5.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@typescript-eslint/parser": ^8.57.2
|
"@typescript-eslint/parser": ^8.58.0
|
||||||
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.0.0"
|
typescript: ">=4.8.4 <6.1.0"
|
||||||
checksum: 10c0/92f3a45f6c2104cef5294bfba972c475b1d3fafb6070efa1178b38cb951e7dfbaf89eae50bfd95f4a476fe51783e218b115bd7cbc09fc9bc7c0ca6c5233861d2
|
checksum: 10c0/ac45c30f6ba9e188a01144708aa845e7ee8bb8a4d4f9aa6d2dce7784852d0821d42b031fee6832069935c3b885feff6d4014e30145b99693d25d7f563266a9f8
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/parser@npm:^8.57.2":
|
"@typescript-eslint/parser@npm:^8.57.2":
|
||||||
version: 8.57.2
|
version: 8.58.0
|
||||||
resolution: "@typescript-eslint/parser@npm:8.57.2"
|
resolution: "@typescript-eslint/parser@npm:8.58.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/scope-manager": "npm:8.57.2"
|
"@typescript-eslint/scope-manager": "npm:8.58.0"
|
||||||
"@typescript-eslint/types": "npm:8.57.2"
|
"@typescript-eslint/types": "npm:8.58.0"
|
||||||
"@typescript-eslint/typescript-estree": "npm:8.57.2"
|
"@typescript-eslint/typescript-estree": "npm:8.58.0"
|
||||||
"@typescript-eslint/visitor-keys": "npm:8.57.2"
|
"@typescript-eslint/visitor-keys": "npm:8.58.0"
|
||||||
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.0.0"
|
typescript: ">=4.8.4 <6.1.0"
|
||||||
checksum: 10c0/afd8a30bd42ac56b212f3182d1b60e4556542eb22147b5b7a9a606d3c79ee35e596baf0bd7672d7e236472d246efc86e06265a46be26150ac12b05e4c45d16a6
|
checksum: 10c0/56c7ec21675cec4730760bfa37c29e42e80b4d6444e2beca55fad9ef53731392270d142797482ea798405be0d7e28ec6c9c16a1ee2ee1c94f73d3bf0ed29763c
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/project-service@npm:8.57.2":
|
"@typescript-eslint/project-service@npm:8.58.0":
|
||||||
version: 8.57.2
|
version: 8.58.0
|
||||||
resolution: "@typescript-eslint/project-service@npm:8.57.2"
|
resolution: "@typescript-eslint/project-service@npm:8.58.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/tsconfig-utils": "npm:^8.57.2"
|
"@typescript-eslint/tsconfig-utils": "npm:^8.58.0"
|
||||||
"@typescript-eslint/types": "npm:^8.57.2"
|
"@typescript-eslint/types": "npm:^8.58.0"
|
||||||
debug: "npm:^4.4.3"
|
debug: "npm:^4.4.3"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: ">=4.8.4 <6.0.0"
|
typescript: ">=4.8.4 <6.1.0"
|
||||||
checksum: 10c0/f84e3165b0a214318d4bc119018b87c044170d7638945e84bd4cee2d752b62c1797ce722ca1161cd06f48512d0115ef75500e6c8fc01005ad4bb39fb48dd77bf
|
checksum: 10c0/e6d0cb2f7708ccb31a2ff9eb35817d4999c26e1f1cd3c607539e21d0c73a234daa77c73ee1163bc4e8b139252d619823c444759f1ddabdd138cab4885e9c9794
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/scope-manager@npm:8.57.2":
|
"@typescript-eslint/scope-manager@npm:8.58.0":
|
||||||
version: 8.57.2
|
version: 8.58.0
|
||||||
resolution: "@typescript-eslint/scope-manager@npm:8.57.2"
|
resolution: "@typescript-eslint/scope-manager@npm:8.58.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/types": "npm:8.57.2"
|
"@typescript-eslint/types": "npm:8.58.0"
|
||||||
"@typescript-eslint/visitor-keys": "npm:8.57.2"
|
"@typescript-eslint/visitor-keys": "npm:8.58.0"
|
||||||
checksum: 10c0/532b1a97a5c2fce51400fa1a94e09615b4df84ce1f2d107206a3f3935074cada396a3e30f155582a698981832868e1afea1641ff779ad9456fdc94169b7def64
|
checksum: 10c0/bd5c16780f22d62359af0f69909f38a15fa3c55e609124a7cd5c2a04322fe41e586d81066f3ad1dcc3c1eff24dbcb48b78d099626d611fbd680c20c005d48f1d
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/tsconfig-utils@npm:8.57.2, @typescript-eslint/tsconfig-utils@npm:^8.57.2":
|
"@typescript-eslint/tsconfig-utils@npm:8.58.0, @typescript-eslint/tsconfig-utils@npm:^8.58.0":
|
||||||
version: 8.57.2
|
version: 8.58.0
|
||||||
resolution: "@typescript-eslint/tsconfig-utils@npm:8.57.2"
|
resolution: "@typescript-eslint/tsconfig-utils@npm:8.58.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: ">=4.8.4 <6.0.0"
|
typescript: ">=4.8.4 <6.1.0"
|
||||||
checksum: 10c0/199dad2d96efc88ce94f5f3e12e97205537bf7a7152e56ef1d84dfbe7bd1babebea9b9f396c01b6c447505a4eb02c1cbbd2c28828c587b51b41b15d017a11d2f
|
checksum: 10c0/0a07fe1a28b2513e625882bc8d4c4e0c5a105cdbcb987beae12fc66dbe71dc9638013e4d1fa8ad10d828a2acd5e3fed987c189c00d41fed0e880009f99adf1b2
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/type-utils@npm:8.57.2":
|
"@typescript-eslint/type-utils@npm:8.58.0":
|
||||||
version: 8.57.2
|
version: 8.58.0
|
||||||
resolution: "@typescript-eslint/type-utils@npm:8.57.2"
|
resolution: "@typescript-eslint/type-utils@npm:8.58.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/types": "npm:8.57.2"
|
"@typescript-eslint/types": "npm:8.58.0"
|
||||||
"@typescript-eslint/typescript-estree": "npm:8.57.2"
|
"@typescript-eslint/typescript-estree": "npm:8.58.0"
|
||||||
"@typescript-eslint/utils": "npm:8.57.2"
|
"@typescript-eslint/utils": "npm:8.58.0"
|
||||||
debug: "npm:^4.4.3"
|
debug: "npm:^4.4.3"
|
||||||
ts-api-utils: "npm:^2.4.0"
|
ts-api-utils: "npm:^2.5.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.0.0"
|
typescript: ">=4.8.4 <6.1.0"
|
||||||
checksum: 10c0/9c479cd0e809d26b7da7b31e830520bc016aaf528bc10a8b8279374808cb76a27f1b4adc77c84156417dc70f6a9e8604f47717b555a27293da2b9b5cfda70411
|
checksum: 10c0/1223733d41f8463be92ef1ad048d546f9663152212b22dc968abbd9f8e4486bd4082e16baa51d2d281e0d4815563bc4b1ecf01684e2940b7897ba17aa26d1196
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/types@npm:8.57.2, @typescript-eslint/types@npm:^8.57.2":
|
"@typescript-eslint/types@npm:8.58.0, @typescript-eslint/types@npm:^8.58.0":
|
||||||
version: 8.57.2
|
version: 8.58.0
|
||||||
resolution: "@typescript-eslint/types@npm:8.57.2"
|
resolution: "@typescript-eslint/types@npm:8.58.0"
|
||||||
checksum: 10c0/3cd87dd77d28b3ac2fed56a17909b0d11633628d4d733aa148dfd7af72e2cc3ec0e6114b72fac0ff538e8a47e907b4b10dab4095170ae1bd73719ef0b8eaf2e7
|
checksum: 10c0/f2fe1321758a04591c20d77caba956ae76b77cff0b976a0224b37077d80b1ebd826874d15ec79c3a3b7d57ee5679e5d10756db1b082bde3d51addbd3a8431d38
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree@npm:8.57.2":
|
"@typescript-eslint/typescript-estree@npm:8.58.0":
|
||||||
version: 8.57.2
|
version: 8.58.0
|
||||||
resolution: "@typescript-eslint/typescript-estree@npm:8.57.2"
|
resolution: "@typescript-eslint/typescript-estree@npm:8.58.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/project-service": "npm:8.57.2"
|
"@typescript-eslint/project-service": "npm:8.58.0"
|
||||||
"@typescript-eslint/tsconfig-utils": "npm:8.57.2"
|
"@typescript-eslint/tsconfig-utils": "npm:8.58.0"
|
||||||
"@typescript-eslint/types": "npm:8.57.2"
|
"@typescript-eslint/types": "npm:8.58.0"
|
||||||
"@typescript-eslint/visitor-keys": "npm:8.57.2"
|
"@typescript-eslint/visitor-keys": "npm:8.58.0"
|
||||||
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.4.0"
|
ts-api-utils: "npm:^2.5.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: ">=4.8.4 <6.0.0"
|
typescript: ">=4.8.4 <6.1.0"
|
||||||
checksum: 10c0/2c5d143f0abbafd07a45f0b956aab5d6487b27f74fe93bee93e0a3f8edc8913f1522faf8d7d5215f3809a8d12f5729910ea522156552f2481b66e6d05ab311ae
|
checksum: 10c0/a8cb94cb765b27740a54f9b5378bd8f0dc49e301ceed99a0791dc9d1f61c2a54e3212f7ed9120c8c2df80104ad3117150cf5e7fe8a0b7eec3ed04969a79b103e
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/utils@npm:8.57.2":
|
"@typescript-eslint/utils@npm:8.58.0":
|
||||||
version: 8.57.2
|
version: 8.58.0
|
||||||
resolution: "@typescript-eslint/utils@npm:8.57.2"
|
resolution: "@typescript-eslint/utils@npm:8.58.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@eslint-community/eslint-utils": "npm:^4.9.1"
|
"@eslint-community/eslint-utils": "npm:^4.9.1"
|
||||||
"@typescript-eslint/scope-manager": "npm:8.57.2"
|
"@typescript-eslint/scope-manager": "npm:8.58.0"
|
||||||
"@typescript-eslint/types": "npm:8.57.2"
|
"@typescript-eslint/types": "npm:8.58.0"
|
||||||
"@typescript-eslint/typescript-estree": "npm:8.57.2"
|
"@typescript-eslint/typescript-estree": "npm:8.58.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.0.0"
|
typescript: ">=4.8.4 <6.1.0"
|
||||||
checksum: 10c0/5771f3d4206004cc817a6556a472926b4c1c885dc448049c10ffab1d5aac7bd59450a391fb57ce8ef31a8367e9c8ddb3bc9370c4e83fc8b61f50fd5189390e8f
|
checksum: 10c0/457e01a6e6d954dbfe13c49ece3cf8a55e5d8cf19ea9ae7086c0e205d89e3cdbb91153062ab440d2e78ad3f077b174adc42bfb1b6fc24299020a0733e7f9c11c
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@typescript-eslint/visitor-keys@npm:8.57.2":
|
"@typescript-eslint/visitor-keys@npm:8.58.0":
|
||||||
version: 8.57.2
|
version: 8.58.0
|
||||||
resolution: "@typescript-eslint/visitor-keys@npm:8.57.2"
|
resolution: "@typescript-eslint/visitor-keys@npm:8.58.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/types": "npm:8.57.2"
|
"@typescript-eslint/types": "npm:8.58.0"
|
||||||
eslint-visitor-keys: "npm:^5.0.0"
|
eslint-visitor-keys: "npm:^5.0.0"
|
||||||
checksum: 10c0/8ceb8c228bf97b3e4b343bf6e42a91998d2522f459eb6b53c6bfad4898a9df74295660893dee6b698bdbbda537e968bfc13a3c56fc341089ebfba13db766a574
|
checksum: 10c0/75f3c9c097a308cc6450822a0f81d44c8b79b524e99dd2c41ded347b12f148ab3bd459ce9cc6bd00f8f0725c5831baab6d2561596ead3394ab76dddbeb32cce1
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -2762,11 +2762,11 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"baseline-browser-mapping@npm:^2.9.0":
|
"baseline-browser-mapping@npm:^2.9.0":
|
||||||
version: 2.10.11
|
version: 2.10.12
|
||||||
resolution: "baseline-browser-mapping@npm:2.10.11"
|
resolution: "baseline-browser-mapping@npm:2.10.12"
|
||||||
bin:
|
bin:
|
||||||
baseline-browser-mapping: dist/cli.cjs
|
baseline-browser-mapping: dist/cli.cjs
|
||||||
checksum: 10c0/6ae44b653c26de8ea7b75a109c0771c95c9676c32a11aff25f7c10b2ddeb864d2c387243a0ec083dffe3b76a7aacf2d777fa7e5a6df16e3a2624c1573e116285
|
checksum: 10c0/391d354240160546c8248317698b61f21f287cc6444766414c2d299a8880045e605ed97e8d8cd198a0b9dfaa4e73c2fa765bbef089474533a904733b1dc9a363
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -2782,25 +2782,25 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"brace-expansion@npm:^1.1.7":
|
"brace-expansion@npm:^1.1.7":
|
||||||
version: 1.1.12
|
version: 1.1.13
|
||||||
resolution: "brace-expansion@npm:1.1.12"
|
resolution: "brace-expansion@npm:1.1.13"
|
||||||
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/975fecac2bb7758c062c20d0b3b6288c7cc895219ee25f0a64a9de662dbac981ff0b6e89909c3897c1f84fa353113a721923afdec5f8b2350255b097f12b1f73
|
checksum: 10c0/384c61bb329b6adfdcc0cbbdd108dc19fb5f3e84ae15a02a74f94c6c791b5a9b035aae73b2a51929a8a478e2f0f212a771eb6a8b5b514cccfb8d0c9f2ce8cbd8
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"brace-expansion@npm:^2.0.2":
|
"brace-expansion@npm:^2.0.2":
|
||||||
version: 2.0.2
|
version: 2.0.3
|
||||||
resolution: "brace-expansion@npm:2.0.2"
|
resolution: "brace-expansion@npm:2.0.3"
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: "npm:^1.0.0"
|
balanced-match: "npm:^1.0.0"
|
||||||
checksum: 10c0/6d117a4c793488af86b83172deb6af143e94c17bc53b0b3cec259733923b4ca84679d506ac261f4ba3c7ed37c46018e2ff442f9ce453af8643ecd64f4a54e6cf
|
checksum: 10c0/468436c9b2fa6f9e64d0cff8784b21300677571a7196e258593e95e7c3db9973a80fbafdb0f01404d5d298a04dc666eae1fc3c9052e2edbb9f2510541deeddfe
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"brace-expansion@npm:^5.0.2":
|
"brace-expansion@npm:^5.0.5":
|
||||||
version: 5.0.5
|
version: 5.0.5
|
||||||
resolution: "brace-expansion@npm:5.0.5"
|
resolution: "brace-expansion@npm:5.0.5"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -2914,9 +2914,9 @@ __metadata:
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"caniuse-lite@npm:^1.0.30001759":
|
"caniuse-lite@npm:^1.0.30001759":
|
||||||
version: 1.0.30001781
|
version: 1.0.30001782
|
||||||
resolution: "caniuse-lite@npm:1.0.30001781"
|
resolution: "caniuse-lite@npm:1.0.30001782"
|
||||||
checksum: 10c0/79e77d8759a55e90f0f5db96ab9e7925c7b2e3021f77852e647e45f64f7dc701954174188438e84b810824afc16d706c64a38f20f9c1ed9ac174b6362d33325f
|
checksum: 10c0/f11685de4ce1f0bc16d385fc0a07b0877da0b14af8bf510cee6a3cdfe9da1602360e1f11320e92d4f5d63cd6bec8b43539de25ee78ff94bdb7ec0fa3cce5200c
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -3208,14 +3208,7 @@ __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.1, discord-api-types@npm:^0.38.33, discord-api-types@npm:^0.38.40, discord-api-types@npm:^0.38.41":
|
||||||
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
|
||||||
|
|
@ -3275,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.326
|
version: 1.5.329
|
||||||
resolution: "electron-to-chromium@npm:1.5.326"
|
resolution: "electron-to-chromium@npm:1.5.329"
|
||||||
checksum: 10c0/8a773ac36b36c5e62c3704a27adbe4d2a72961a8d46782c96e2515a13fbcc6f9e850b1e478482775ee3874ac4b42cd5c32870b2faf4c8e1c7569e172e310c314
|
checksum: 10c0/a275d7dd7ef26b98d304d37831684614b575d91d5186d3764e7c10114677ba84f4b9ee54a7ef326f63f2dbb2ca883582e3ef9925d9aee8562e1982fa42c94c43
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -4798,7 +4791,8 @@ __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"
|
||||||
|
|
@ -4813,7 +4807,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"
|
||||||
|
|
@ -4914,9 +4908,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.2.7
|
version: 11.3.2
|
||||||
resolution: "lru-cache@npm:11.2.7"
|
resolution: "lru-cache@npm:11.3.2"
|
||||||
checksum: 10c0/549cdb59488baa617135fc12159cafb1a97f91079f35093bb3bcad72e849fc64ace636d244212c181dfdf1a99bbfa90757ff303f98561958ee4d0f885d9bd5f7
|
checksum: 10c0/1981baec397c1e7875ac7456ea123e1f8016b878bf3a88b083bbe3f791ba77212d0507751a069f3d4c12441d70ca255d150c2a1c489d3cbe5ed6b8c625bb1be2
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -5019,11 +5013,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.4
|
version: 10.2.5
|
||||||
resolution: "minimatch@npm:10.2.4"
|
resolution: "minimatch@npm:10.2.5"
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: "npm:^5.0.2"
|
brace-expansion: "npm:^5.0.5"
|
||||||
checksum: 10c0/35f3dfb7b99b51efd46afd378486889f590e7efb10e0f6a10ba6800428cf65c9a8dedb74427d0570b318d749b543dc4e85f06d46d2858bc8cac7e1eb49a95945
|
checksum: 10c0/6bb058bd6324104b9ec2f763476a35386d05079c1f5fe4fbf1f324a25237cd4534d6813ecd71f48208f4e635c1221899bef94c3c89f7df55698fe373aaae20fd
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -5742,7 +5736,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:
|
||||||
|
|
@ -6373,7 +6367,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"ts-api-utils@npm:^2.4.0":
|
"ts-api-utils@npm:^2.5.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