Compare commits

..

11 Commits

Author SHA1 Message Date
안명현 c864be267f Merge origin/main into myong_dev 2026-04-07 14:57:41 +09:00
안명현 41a97c474e Implement fishing progression features 2026-04-07 14:51:25 +09:00
이정수 e4bcf53308 docs: add project structure documentation and update main index file 2026-04-06 14:28:57 +09:00
mineseo-kim 2a8b6d41dc Merge branch 'delete-redis'
# Conflicts:
#	yarn.lock
2026-04-02 11:18:10 +09:00
mineseo-kim c10822ea87 빌드 성공 2026-04-01 17:41:09 +09:00
mineseo-kim 57d38a4e09 빌드성공 2026-03-31 10:49:35 +09:00
mineseo-kim 57805644fb Merge remote-tracking branch 'origin/main' into delete-redis
# Conflicts:
#	prisma/schema.prisma
#	src/client/KordClient.ts
#	yarn.lock
2026-03-31 10:27:05 +09:00
mineseo-kim aad37ef855 빌드 성공
빌드 성공본 업데이트
2026-03-31 10:26:16 +09:00
mineseo-kim c1d18d7d8f Service 고도화 작업
난 한국어 커밋 메세지가 좋아
2026-03-31 10:13:38 +09:00
mineseo-kim 1f91bfb9bf Merge branch 'main' into delete-redis
# Conflicts:
#	package.json
#	yarn.lock
2026-03-31 09:29:49 +09:00
pandoli365 e43af4f944 레디스 제거 버전 작업 2026-03-30 20:48:03 +09:00
28 changed files with 10028 additions and 259 deletions

View File

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

View File

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

44
Docs/Project_Structure.md Normal file
View File

@ -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)입니다.

242
Docs/database-schema.md Normal file
View File

@ -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)를 함께 맞추는 것이 좋습니다.

View File

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

View File

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

View File

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

8825
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
} }
datasource db { datasource db {
@ -140,9 +141,16 @@ 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
@ -151,11 +159,10 @@ model MiniGameConfig {
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
@ -170,9 +177,9 @@ model RefinementProfile {
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
@ -180,7 +187,27 @@ model RefinementProfile {
@@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

65
src/cache/index.ts vendored
View File

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

View File

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

View File

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

226
src/core/command.ts Normal file
View File

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

49
src/core/db.ts Normal file
View File

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

View File

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

223
src/service/command.ts Normal file
View File

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

83
src/service/test.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

54
tests/core/db.test.ts Normal file
View File

@ -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
View File

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