Compare commits

...

21 Commits

Author SHA1 Message Date
이정수 ad8b47d4ec feat: add console output to logger and remove redundant deferReply in autorole command 2026-04-10 10:25:55 +09:00
이정수 1e31f83ff3 fix(i18n): restore missing channelLocked translation key in ko.ts 2026-04-10 09:40:26 +09:00
이정수 bbc4bed551 Merge branch 'origin/main' into feature/invite-role and resolve conflicts 2026-04-10 09:38:39 +09:00
이정수 6072ab716f refactor: remove invite tracking functionality and migrate member join handling to AutoRoleService 2026-04-07 16:34:08 +09:00
이정수 c4f5e8d53c refactor: remove invite management system and associated command, events, and localization keys 2026-04-07 16:30:20 +09:00
이정수 579e9a8a61 refactor: remove retroactive autorole functionality and associated database tables 2026-04-07 15:14:25 +09:00
이정수 798d3d589c docs: add discord_ui_ux rule file capturing philosophy 2026-04-07 09:07:34 +09:00
이정수 f3f882cd06 feat(autorole): implement multi-role UX, persistence and automatic toggle logic 2026-04-06 18:05:33 +09:00
이정수 c71d263607 chore: update Korean localization strings 2026-04-06 17:16:43 +09:00
이정수 d941daf005 feat: add bot autorole configuration and migrate interaction handling to interactionCreate event 2026-04-06 15:36:45 +09:00
이정수 26cfd356ff feat: add invite command to manage role-based invite code mappings 2026-04-06 15:05:23 +09:00
이정수 107c00cb13 feat: implement AutoRoleService with retroactive assignment and configuration support 2026-04-06 15:04:21 +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
41 changed files with 10334 additions and 321 deletions

View File

@ -0,0 +1,27 @@
---
description: Discord Bot UI/UX Design Philosophy
---
# Discord Bot UI/UX Design Philosophy
When designing or updating Discord command interfaces (Embeds, Components), adhere to the following UI/UX philosophy to ensure a clean, intuitive, and modern user experience.
## 1. Minimal and Non-redundant Information (중복 정보 최소화)
- Do not display information in the Embed that is already visually apparent in the UI components.
- For example, if a `RoleSelectMenuBuilder` allows the user to select roles, use `.addDefaultRoles(ids)` (available in discord.js 14.14+) to display the currently selected roles natively inside the dropdown menu.
- Do NOT list those same roles redundantly as text inside the Embed fields. The Embed should remain concise, showing only titles and essential descriptions or instructions.
## 2. Implicit State (명시적 토글 지양 및 상태 직관화)
- Avoid creating manual On/Off toggle buttons unless absolutely necessary.
- Derive the "Enabled/Disabled" state directly from the user's data naturally. For instance, if the user has selected at least one role (`roleIds.length > 0`), the feature is automatically considered "Active/Enabled". If they clear the selection, the feature is "Disabled".
- This reduces UI clutter (removing unnecessary toggle ActionRows) and aligns with modern design patterns where state implicitly follows the presence of data.
## 3. Persistent and Seamless Interaction (매끄러운 대시보드 유지)
- Component interactions should feel fast and seamless without fragmenting the chat history.
- Always immediately call `await interaction.deferUpdate();` (or equivalent) when handling components (buttons, select menus) to prevent "Unknown interaction" timeout errors.
- Use `await interaction.editReply(...)` with the newly generated UI components to seamlessly update the dashboard frame in place.
- Do NOT generate new follow-up messages or close the menu unilaterally when the user still expects to tweak settings.
## 4. Safe Response Timings (타임아웃 방지)
- When processing `ChatInputCommandInteraction` that might involve a database cold-start connection or external API calls, proactively call `await interaction.deferReply({ ephemeral: true });` right at the start.
- Update the UI with `await interaction.editReply(...)` once business logic resolves, bypassing Discord's strict 3-second timeout limitation and preventing crashes during initial boot load.

View File

@ -12,3 +12,4 @@ LOG_LEVEL=info
# Log directory (kord.log + dated rotations). Relative = from process cwd; use an absolute path on servers
# if the deploy directory is wiped (e.g. Jenkins): LOG_DIR=/var/lib/kord/logs
LOG_DIR=logs

View File

@ -2,7 +2,7 @@ FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn
RUN corepack enable && yarn install
RUN corepack enable && yarn install --immutable
# Generate Prisma Client
COPY prisma ./prisma/
@ -17,9 +17,8 @@ FROM node:20-alpine AS runner
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./
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
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

@ -2,6 +2,11 @@
이 루트 색인 문서는 프로젝트 내의 모든 구조화된 문서를 카테고리별로 모아 탐색을 돕기 위해 작성되었습니다.
## 아키텍처 및 시스템 (Architecture & System)
- [프로젝트 구조 (Project Structure)](Project_Structure.md)
- [데이터베이스 스키마 구조 (Database Schema)](database-schema.md)
## 정책 및 규칙 (Rules)
- [보안 가이드라인 (Security Rules)](Rules/security_guidelines.md)

View File

@ -60,6 +60,7 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
2. **실행**: `yarn start`
3. **Docker**: `docker-compose up -d`를 통해 PostgreSQL 등 로컬 인프라를 실행할 수 있습니다.
## 5. 기능 목록 (Feature List)
- **임시 음성 채널 (Voice)**: 생성기(Generator) 채널을 통해 동적인 음성 채널 생성 및 관리 기능을 제공합니다.

View File

@ -1,6 +1,15 @@
version: '3.8'
services:
kord:
build: .
container_name: kord-bot
env_file:
- .env
depends_on:
- postgres
restart: unless-stopped
postgres:
image: postgres:15-alpine
container_name: kord-postgres

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/voice": "^0.19.2",
"@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",
"discord.js": "^14.25.1",
"dotenv": "^17.3.1",
@ -24,7 +25,7 @@
"eslint": "^10.1.0",
"jest": "^30.3.0",
"prettier": "^3.8.1",
"prisma": "7.6.0",
"prisma": "^7.6.0",
"ts-jest": "^29.4.6",
"tsx": "^4.21.0",
"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

@ -0,0 +1,29 @@
-- CreateEnum
CREATE TYPE "ExcludeType" AS ENUM ('ROLE', 'USER');
-- CreateTable
CREATE TABLE "AutoRoleConfig" (
"guildId" TEXT NOT NULL,
"userRoleId" TEXT,
"botRoleId" TEXT,
"isEnabled" BOOLEAN NOT NULL DEFAULT false,
"botEnabled" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutoRoleConfig_pkey" PRIMARY KEY ("guildId")
);
-- CreateTable
CREATE TABLE "AutoRoleExclude" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"type" "ExcludeType" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AutoRoleExclude_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AutoRoleExclude_guildId_targetId_type_key" ON "AutoRoleExclude"("guildId", "targetId", "type");

View File

@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the column `botRoleId` on the `AutoRoleConfig` table. All the data in the column will be lost.
- You are about to drop the column `userRoleId` on the `AutoRoleConfig` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "AutoRoleConfig" DROP COLUMN "botRoleId",
DROP COLUMN "userRoleId",
ADD COLUMN "botRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "userRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the `AutoRoleExclude` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
DROP TABLE "AutoRoleExclude";
-- DropEnum
DROP TYPE "ExcludeType";

View File

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the `InviteRole` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
DROP TABLE "InviteRole";

View File

@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
datasource db {
@ -16,16 +17,6 @@ model GuildConfig {
updatedAt DateTime @updatedAt
}
model InviteRole {
id String @id @default(uuid())
guildId String
inviteCode String
roleId String
createdAt DateTime @default(now())
@@unique([guildId, inviteCode])
}
model UserSubscription {
userId String @id
tier SubscriptionTier @default(FREE)
@ -140,9 +131,16 @@ enum EventStatus {
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 {
id String @id @default(uuid())
guildId String
@ -151,11 +149,10 @@ model MiniGameConfig {
channelId String?
updatedAt DateTime @updatedAt
@@unique([guildId, gameKey])
@@index([guildId])
@@unique([guildId, gameKey])
}
// 재련 - 유저 상태
model RefinementProfile {
userId String
guildId String
@ -170,9 +167,9 @@ model RefinementProfile {
battleWin Int @default(0)
battleLoss Int @default(0)
dailyBattleCount Int @default(0)
lastBattleReset DateTime @default(now())
isDisabled Boolean @default(false)
lastCheckIn DateTime?
lastBattleReset DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -180,7 +177,21 @@ model RefinementProfile {
@@index([guildId, weaponLevel(sort: Desc)])
}
// 재련 - 레벨별 수치 설정 (수치 대조 및 수정용)
model AutoRoleConfig {
guildId String @id
userRoleIds String[] @default([])
botRoleIds String[] @default([])
isEnabled Boolean @default(false)
botEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model RefinementLevelConfig {
level Int @id
successRate Float
@ -191,15 +202,13 @@ model RefinementLevelConfig {
updatedAt DateTime @updatedAt
}
// 재련 - 레벨 차이별 승률 설정 (승률 곡선 관리)
model RefinementBattleConfig {
levelGap Int @id // (공격자 레벨 - 방어자 레벨)
levelGap Int @id
winRate Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 재련 - 전역 시스템 설정 (일일 제한, 초기 자본 등)
model RefinementSystemConfig {
key String @id
value String
@ -266,3 +275,4 @@ model FishingCollectionEntry {
@@id([userId, guildId, fishId])
@@index([guildId, userId])
}

View File

@ -7,6 +7,7 @@ import { handleGlobalExceptions } from '../utils/errorHandler';
import { connectDB } from '../database';
import { FeverService } from '../services/FeverService';
export class KordClient extends Client {
public commands: Collection<string, any> = new Collection();
@ -17,8 +18,10 @@ export class KordClient extends Client {
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildInvites,
],
partials: [Partials.Message, Partials.Channel, Partials.GuildMember],
});
}

74
src/commands/autorole.ts Normal file
View File

@ -0,0 +1,74 @@
import {
SlashCommandBuilder,
ChatInputCommandInteraction,
PermissionFlagsBits,
EmbedBuilder,
Colors,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
RoleSelectMenuBuilder,
ComponentType
} from 'discord.js';
import { Command, CommandTrait } from '../core/command';
import { autoRoleService } from '../services/AutoRoleService';
import { t, SupportedLocale } from '../i18n';
class AutoRoleCommand extends Command {
protected override readonly trait = CommandTrait.General;
protected override guildOnly = true;
protected override define() {
return new SlashCommandBuilder()
.setName('autorole')
.setDescription('Configure automatic role assignment upon joining.')
.setDescriptionLocalizations({
ko: '입장 시 역할을 자동으로 부여하는 기능을 설정합니다.',
})
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator);
}
protected override async handle(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
const guild = interaction.guild!;
const dashboard = await generateAutoRoleDashboard(guild, locale);
await interaction.editReply({
...dashboard
});
}
}
export async function generateAutoRoleDashboard(guild: import('discord.js').Guild, locale: SupportedLocale) {
const config = await autoRoleService.getConfig(guild.id);
const embed = new EmbedBuilder()
.setTitle(t(locale, 'commands.autorole.statusTitle'))
.setColor(Colors.Blue)
.setDescription(t(locale, 'commands.autorole.description') || '유저 및 봇이 서버에 접속할 때 자동으로 부여할 기본 역할을 선택하세요. 역할을 선택하면 즉시 활성화됩니다.');
const userSelect = new RoleSelectMenuBuilder()
.setCustomId('autorole_select_user')
.setPlaceholder(t(locale, 'commands.autorole.userRolePlaceholder'))
.setMaxValues(10);
if (config?.userRoleIds && config.userRoleIds.length > 0) {
userSelect.addDefaultRoles(config.userRoleIds);
}
const rowUserRole = new ActionRowBuilder<RoleSelectMenuBuilder>().addComponents(userSelect);
const botSelect = new RoleSelectMenuBuilder()
.setCustomId('autorole_select_bot')
.setPlaceholder(t(locale, 'commands.autorole.botRolePlaceholder'))
.setMaxValues(10);
if (config?.botRoleIds && config.botRoleIds.length > 0) {
botSelect.addDefaultRoles(config.botRoleIds);
}
const rowBotRole = new ActionRowBuilder<RoleSelectMenuBuilder>().addComponents(botSelect);
return {
embeds: [embed],
components: [rowUserRole, rowBotRole]
};
}
export default new AutoRoleCommand().toModule();

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

@ -0,0 +1,232 @@
import {
SlashCommandBuilder,
SlashCommandOptionsOnlyBuilder,
SlashCommandSubcommandsOnlyBuilder,
ChatInputCommandInteraction,
} from 'discord.js';
/** Values returned from {@link SlashCommandBuilder} after chaining options or subcommands only. */
export type SlashCommandData =
| SlashCommandBuilder
| SlashCommandOptionsOnlyBuilder
| SlashCommandSubcommandsOnlyBuilder;
import { prisma } from '../database';
import { SupportedLocale } from '../i18n';
import { SubscriptionTier } from '@prisma/client';
/**
* · .
*
* - help , , , .
* - `Command` .
*/
export enum CommandTrait {
/** 음악 재생·대기열 등 */
Music = 'music',
/** 미니게임 */
Minigame = 'minigame',
/** 방송 연동·알림 등 */
Broadcast = 'broadcast',
/** 위에 해당하지 않는 일반 명령(설정·감사·음성 등) */
General = 'general',
}
/** {@link CommandTrait.General}을 제외한 특성은 `guild_payment` 플래그가 필요합니다. */
export function traitRequiresPayment(trait: CommandTrait): boolean {
return (
trait === CommandTrait.Music || trait === CommandTrait.Minigame || trait === CommandTrait.Broadcast
);
}
/**
* .
*
* @returns `true`. `reply` `false`.
*/
export async function ensureGuildPaidForTrait(
interaction: ChatInputCommandInteraction,
trait: CommandTrait,
): Promise<boolean> {
if (!traitRequiresPayment(trait)) {
return true;
}
const guildId = interaction.guildId;
if (!guildId) {
const content = '이 명령은 서버에서만 사용할 수 있습니다.';
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content });
} else {
await interaction.reply({ content, ephemeral: true });
}
return false;
}
// Payment flags were replaced by subscription tiers.
// A guild is considered "paid" if it has an owner whose subscription tier is above FREE.
const ownership = await prisma.guildOwnership.findUnique({
where: { guildId },
include: { owner: true },
});
const paid = ownership?.owner?.tier != null && ownership.owner.tier !== SubscriptionTier.FREE;
if (!paid) {
const content = '결제가 되지않았습니다';
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content });
} else {
await interaction.reply({ content, ephemeral: true });
}
return false;
}
return true;
}
/**
* `CommandLoader` .
*
* `src/handlers/CommandLoader.ts` `default` export가
* `data`( ) `execute`( ) ,
* `client.commands.set(command.data.name, command)` .
* , .
*
* `trait` {@link Command.toModule} , .
*/
export type CommandModule = {
data: SlashCommandData;
execute: (interaction: ChatInputCommandInteraction, locale: SupportedLocale) => Promise<void>;
trait?: CommandTrait;
};
/**
* .
*
* ** **
* 1. {@link trait} {@link CommandTrait} .
* 2. {@link define} `SlashCommandBuilder` ··· .
* 3. {@link handle} (·DB· ) .
* 4. `export default new MyCommand().toModule()` `toModule()`
* `commands/*.ts` (`trait` ).
*
* ** ** (`interactionCreate` `execute`)
* 1. `guildOnly === true` DM .
* 2. {@link CommandTrait.Music} / {@link CommandTrait.Minigame} / {@link CommandTrait.Broadcast}
* `guild_payment` `true` . ( ·`false` )
* 3. {@link beforeHandle} `false` ( ) {@link handle} .
* 4. {@link handle} .
*
* `events/interactionCreate.ts` resolve한 `command.execute(interaction, locale)` ,
* -only .
*/
export abstract class Command {
private cachedData: SlashCommandData | null = null;
/**
* . .
*
* : 음악 {@link CommandTrait.Music}, {@link CommandTrait.Minigame},
* {@link CommandTrait.Broadcast}, {@link CommandTrait.General}.
*/
protected abstract readonly trait: CommandTrait;
/**
* `true` **() ** .
*
* DM이나 {@link handle}
* (ephemeral) return .
* (·· ) .
*/
protected guildOnly = false;
/**
* .
*
* {@link define} .
* `data` .
*/
get data(): SlashCommandData {
if (!this.cachedData) {
this.cachedData = this.define();
}
return this.cachedData;
}
/**
* (, , , , , `setDefaultMemberPermissions` )
* `SlashCommandBuilder` .
*/
protected abstract define(): SlashCommandData;
/**
* .
*
* `interaction` , `locale` / resolve된 .
*/
protected abstract handle(
interaction: ChatInputCommandInteraction,
locale: SupportedLocale,
): Promise<void>;
/**
* {@link handle} .
*
* , rate limit, ** **
* .
*
* @returns `true` {@link handle} .
* `false` ** `reply`/`deferReply` **
* `handle` .
*/
protected async beforeHandle(
_interaction: ChatInputCommandInteraction,
_locale: SupportedLocale,
): Promise<boolean> {
return true;
}
/**
* / .
*
* -only {@link ensureGuildPaidForTrait} `beforeHandle` `handle` .
* .
*/
async execute(
interaction: ChatInputCommandInteraction,
locale: SupportedLocale,
): Promise<void> {
if (this.guildOnly && !interaction.inGuild()) {
const content = 'This command can only be used in a server.';
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content });
} else {
await interaction.reply({ content, ephemeral: true });
}
return;
}
if (!(await ensureGuildPaidForTrait(interaction, this.trait))) {
return;
}
if (!(await this.beforeHandle(interaction, locale))) {
return;
}
await this.handle(interaction, locale);
}
/**
* `CommandModule` `default export` .
*
* `execute` , `this` .
*/
toModule(): CommandModule {
return {
data: this.data,
execute: (interaction, locale) => this.execute(interaction, locale),
trait: this.trait,
};
}
}

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,12 +1,10 @@
import { Events, Guild } from 'discord.js';
import { InviteService } from '../services/InviteService';
import { PresenceService } from '../services/PresenceService';
export default {
name: Events.GuildCreate,
once: false,
async execute(guild: Guild) {
await InviteService.cacheGuildInvites(guild);
PresenceService.updatePresence(guild.client);
},
};

View File

@ -1,10 +1,10 @@
import { Events, GuildMember } from 'discord.js';
import { InviteService } from '../services/InviteService';
import { autoRoleService } from '../services/AutoRoleService';
export default {
name: Events.GuildMemberAdd,
once: false,
async execute(member: GuildMember) {
await InviteService.handleMemberAdd(member);
await autoRoleService.handleMemberJoin(member);
},
};

View File

@ -145,6 +145,40 @@ export default {
}, locale);
}
}
else if (interaction.isButton() && interaction.customId.startsWith('autorole_')) {
const locale = await getInteractionLocale(interaction);
const { autoRoleService } = require('../services/AutoRoleService');
await withErrorHandler(interaction, async () => {
// 타임아웃 방지를 위해 즉시 승인
await interaction.deferUpdate();
// 나머지 버튼 처리 (현재 사용 안함)
}, locale);
}
else if (interaction.isRoleSelectMenu() && interaction.customId.startsWith('autorole_select_')) {
const locale = await getInteractionLocale(interaction);
const { autoRoleService } = require('../services/AutoRoleService');
await withErrorHandler(interaction, async () => {
// 타임아웃 방지를 위해 즉시 승인
await interaction.deferUpdate();
const guild = interaction.guild!;
const isBot = interaction.customId.includes('bot');
const roleIds = interaction.values;
await autoRoleService.updateConfig(guild.id, {
[isBot ? 'botRoleIds' : 'userRoleIds']: roleIds
});
const { generateAutoRoleDashboard } = require('../commands/autorole');
const dashboard = await generateAutoRoleDashboard(guild, locale);
await interaction.editReply({
content: '',
...dashboard
});
}, locale);
}
else if (interaction.isModalSubmit()) {
const customId = interaction.customId;
if (customId.startsWith('modal_vc_')) {

View File

@ -1,10 +0,0 @@
import { Events, Invite } from 'discord.js';
import { InviteService } from '../services/InviteService';
export default {
name: Events.InviteCreate,
once: false,
async execute(invite: Invite) {
await InviteService.handleInviteCreate(invite);
},
};

View File

@ -1,10 +0,0 @@
import { Events, Invite } from 'discord.js';
import { InviteService } from '../services/InviteService';
export default {
name: Events.InviteDelete,
once: false,
async execute(invite: Invite) {
await InviteService.handleInviteDelete(invite);
},
};

View File

@ -1,11 +1,12 @@
import { Events } from 'discord.js';
import { KordClient } from '../client/KordClient';
import { logger } from '../utils/logger';
import { InviteService } from '../services/InviteService';
import { VoiceService } from '../services/VoiceService';
import { PresenceService } from '../services/PresenceService';
import { EventService } from '../services/EventService';
import { auditLogService } from '../services/AuditLogService';
import { env } from '../config/env';
export default {
@ -13,7 +14,6 @@ export default {
once: true,
async execute(client: KordClient) {
logger.info(`Ready! Logged in as ${client.user?.tag}`);
await InviteService.cacheAllInvites(client);
await VoiceService.syncChannels(client);
PresenceService.startActivePresence(client);
EventService.startReminderLoop(client);
@ -23,6 +23,7 @@ export default {
await client.application?.commands.set(commandsData);
logger.info(`Successfully registered ${commandsData.length} global application commands.`);
} catch (e) {
logger.error('Failed to register global commands', e);
}

View File

@ -191,6 +191,26 @@ export const en: TranslationSchema = {
status: 'Status',
},
},
autorole: {
description: 'Configure automatic role assignment upon joining.',
statusTitle: 'Auto Role Configuration Status',
userRoleLabel: 'User Role',
botRoleLabel: 'Bot Role',
statusLabel: 'User Auto Assignment',
botStatusLabel: 'Bot Auto Assignment',
userRolePlaceholder: 'Select default user role',
botRolePlaceholder: 'Select default bot role',
toggleUserEnable: '🟢 Enable User AutoRole',
toggleUserDisable: '🔴 Disable User AutoRole',
toggleBotEnable: '🟢 Enable Bot AutoRole',
toggleBotDisable: '🔴 Disable Bot AutoRole',
notSet: 'Not Set',
enabled: 'Enabled',
disabled: 'Disabled',
updateSuccess: 'Auto role settings have been updated.',
permissionsError: 'Failed to assign role due to low bot hierarchy or missing permissions.',
suspendNotice: 'Auto role assignment has been suspended due to insufficient permissions. Please check the bot\'s permissions and role hierarchy.',
},
music: {
description: 'Play YouTube audio in voice channels.',
addDescription: 'Search YouTube or add a video URL to the queue.',
@ -328,8 +348,6 @@ export const en: TranslationSchema = {
VOICE_GLOBAL: 'Voice Channels (Global)',
VOICE_GENERATOR_CHANNEL: 'Voice Generator Channel',
VOICE_GENERATOR_CATEGORY: 'Voice Generator Category',
INVITE_TRACKING: 'Invite Tracking',
INVITE_ROLE_HIERARCHY: 'Invite Role Assignment (Hierarchy)',
MIMIC_WEBHOOK: 'Message Mimic (Webhook)',
},
},
@ -388,7 +406,6 @@ export const en: TranslationSchema = {
BOOT: 'Boot',
VOICE: 'Voice',
PERMISSION: 'Permission',
INVITE: 'Invite',
},
},
config: {

View File

@ -4,6 +4,7 @@ import { TranslationSchema } from '../types';
* . en.ts와 1:1로 .
*/
export const ko: TranslationSchema = {
// ── 에러 메시지 ─────────────────────────────────────────
errors: {
E1001: {
userMessage: '사용자 제한 값이 올바르지 않습니다.',
@ -20,6 +21,7 @@ export const ko: TranslationSchema = {
userMessage: '선택한 사용자가 음성 채널에 없습니다.',
resolution: '작업 전에 해당 사용자가 채널에 있는지 확인해 주세요.',
},
E2001: {
userMessage: '봇에게 채널을 관리할 권한이 부족합니다.',
resolution: '서버 관리자에게 봇에게 「채널 관리」 권한을 부여해 달라고 요청해 주세요.',
@ -74,19 +76,22 @@ export const ko: TranslationSchema = {
errorTitles: {
USER_INPUT: '입력을 확인해주세요',
PERMISSION: '권한이 부족합니다',
BOT_INTERNAL: '문제가 발생했습니다',
BOT_INTERNAL: '내부 오류가 발생했습니다.',
DISCORD_API: '일시적인 문제입니다.',
},
errorFields: {
resolution: '💡 해결 방법',
},
// ── 음성 채널 ───────────────────────────────────────────
voice: {
channelReady: '{{owner}}, 임시 채널이 준비되었습니다! 아래 드롭다운 메뉴로 관리하세요.',
defaultRoomName: '{{username}}의 방',
controlPanel: {
placeholder: '채널 설정 관리',
placeholder: '⚙️ 채널 설정 관리',
rename: '채널 이름 변경',
limit: '인원 제한 설정',
lock: '채널 잠금 / 해제',
@ -94,6 +99,7 @@ export const ko: TranslationSchema = {
ban: '유저 차단 / 숨기기',
transfer: '소유권 이전',
},
responses: {
channelLocked: '채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.',
channelUnlocked: '채널 잠금이 해제되었습니다! 누구나 참여할 수 있습니다.',
@ -119,6 +125,7 @@ export const ko: TranslationSchema = {
setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!',
createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성·설정했습니다!',
},
voiceConfig: {
description: '서버의 임시 음성 채널 설정을 관리합니다.',
setNameTitle: '기본 이름 템플릿 설정',
@ -129,6 +136,7 @@ export const ko: TranslationSchema = {
templateLabel: '이름 템플릿',
limitLabel: '기본 인원 제한',
setSuccess: '서버 임시 채널 설정이 업데이트되었습니다.',
limitValue: '{{limit}}명 (0 = 무제한)',
},
language: {
@ -140,6 +148,7 @@ export const ko: TranslationSchema = {
userSet: '개인 언어가 **{{locale}}**(으)로 설정되었습니다.',
serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.',
serverPermissionDenied: '서버 언어 변경은 서버 관리자만 할 수 있습니다.',
},
event: {
description: '서버 이벤트 일정을 관리합니다.',
@ -163,6 +172,7 @@ export const ko: TranslationSchema = {
'**시작 시각:** {{startsAt}}\n**상대 시간:** {{relative}}\n**상태:** {{status}}\n**리마인더:** {{reminder}}\n**채널:** {{channel}}',
cancelSuccess: '`{{id}}` 이벤트가 취소되었습니다.',
cancelNotFound: 'ID가 `{{id}}`인 예약 이벤트를 찾을 수 없습니다.',
announceSuccess: '`{{id}}` 이벤트를 {{channel}} 채널에 공지했습니다.',
announceNotAvailable: '이 이벤트에는 사용할 수 있는 공지 채널이 설정되어 있지 않습니다.',
startAnnouncementTitle: '이벤트 시작',
@ -174,6 +184,7 @@ export const ko: TranslationSchema = {
'`0,10,60`처럼 0 이상의 분을 쉼표로 구분해 입력해 주세요. 비우면 자동 공지를 사용하지 않습니다.',
invalidPastDateTime: '과거 시각으로는 이벤트를 예약할 수 없습니다.',
invalidPastDateTimeResolution: '미래 시각을 선택한 뒤 다시 시도해 주세요.',
statusScheduled: '예약됨',
statusCancelled: '취소됨',
statusCompleted: '완료됨',
@ -189,6 +200,26 @@ export const ko: TranslationSchema = {
status: '상태',
},
},
autorole: {
description: '입장 시 역할을 자동으로 부여하는 기능을 설정합니다.',
statusTitle: '자동 역할 부여 설정 상태',
userRoleLabel: '일반 유저 역할',
botRoleLabel: '봇 역할',
statusLabel: '유저 자동 부여',
botStatusLabel: '봇 자동 부여',
userRolePlaceholder: '유저 기본 역할을 선택하세요',
botRolePlaceholder: '봇 기본 역할을 선택하세요',
toggleUserEnable: '🟢 유저 자동부여 켜기',
toggleUserDisable: '🔴 유저 자동부여 끄기',
toggleBotEnable: '🟢 봇 자동부여 켜기',
toggleBotDisable: '🔴 봇 자동부여 끄기',
notSet: '미설정',
enabled: '활성',
disabled: '비활성',
updateSuccess: '자동 역할 설정이 업데이트되었습니다.',
permissionsError: '봇의 역할 순위가 낮거나 권한이 부족하여 역할을 부여할 수 없습니다.',
suspendNotice: '권한 부족으로 인해 자동 역할 부여 기능이 일시 중지되었습니다. 봇의 권한과 역할 순위를 확인해 주세요.',
},
music: {
description: '음성 채널에서 YouTube 오디오를 재생합니다.',
addDescription: 'YouTube를 검색하거나 영상 URL을 재생 목록에 추가합니다.',
@ -322,13 +353,13 @@ export const ko: TranslationSchema = {
summaryIssue: '{{fail}}건 실패 · {{warn}}건 경고가 있습니다.',
hierarchyWarning:
"봇 역할(위치: {{botPos}})이 '{{role}}'(위치: {{targetPos}})보다 위에 있어야 해당 역할을 관리할 수 있습니다.",
features: {
BASIC: '기본 봇 기능',
VOICE_GLOBAL: '임시 음성 채널 (전역)',
VOICE_GENERATOR_CHANNEL: '음성 생성기 채널',
VOICE_GENERATOR_CATEGORY: '음성 생성기 카테고리',
INVITE_TRACKING: '초대 추적',
INVITE_ROLE_HIERARCHY: '초대 역할 부여 (계층 검사)',
MIMIC_WEBHOOK: '메시지 흉내 (Webhook)',
},
},
@ -385,12 +416,13 @@ export const ko: TranslationSchema = {
expired: '시간이 만료되었습니다. `/setup`을 다시 실행해 주세요.',
defaultCategoryName: '음성 채널',
defaultGeneratorName: '채널 생성하기',
auditCategories: {
SYSTEM: '시스템',
BOOT: '부팅',
VOICE: '음성',
PERMISSION: '권한',
INVITE: '초대',
},
},
config: {
@ -398,17 +430,20 @@ export const ko: TranslationSchema = {
noOptions: '변경할 옵션을 하나 이상 선택해 주세요.',
mimic: {
label: '흉내(Mimic)',
enabled: '활성',
disabled: '비활성',
enabled: '활성',
disabled: '비활성',
},
emoji: {
label: '큰 이모지(Big Emoji)',
enabled: '활성',
disabled: '비활성',
enabled: '활성',
disabled: '비활성',
},
},
},
// ── 모달 ────────────────────────────────────────────────
modals: {
renameTitle: '음성 채널 이름 변경',
renameLabel: '새 채널 이름',
@ -416,12 +451,16 @@ export const ko: TranslationSchema = {
limitLabel: '인원 제한 (0 = 무제한, 199)',
},
// ── 셀렉트 메뉴 플레이스홀더 ────────────────────────────
selects: {
kickUser: '추방할 유저를 선택하세요',
banUser: '차단할 유저를 선택하세요',
transferOwner: '소유권을 이전할 유저를 선택하세요',
},
// ── 상태 메시지 ──────────────────────────────────────────
presence: {
servers: '{{guildCount}}개의 서버에서 작동 중',
help: '/help 명령어를 확인하세요',

View File

@ -145,6 +145,26 @@ export interface TranslationSchema {
status: string;
};
};
autorole: {
description: string;
statusTitle: string;
userRoleLabel: string;
botRoleLabel: string;
statusLabel: string;
botStatusLabel: string;
userRolePlaceholder: string;
botRolePlaceholder: string;
toggleUserEnable: string;
toggleUserDisable: string;
toggleBotEnable: string;
toggleBotDisable: string;
notSet: string;
enabled: string;
disabled: string;
updateSuccess: string;
permissionsError: string;
suspendNotice: string;
};
music: {
description: string;
addDescription: string;
@ -282,8 +302,6 @@ export interface TranslationSchema {
VOICE_GLOBAL: string;
VOICE_GENERATOR_CHANNEL: string;
VOICE_GENERATOR_CATEGORY: string;
INVITE_TRACKING: string;
INVITE_ROLE_HIERARCHY: string;
MIMIC_WEBHOOK: string;
};
};
@ -305,7 +323,6 @@ export interface TranslationSchema {
BOOT: string;
VOICE: string;
PERMISSION: string;
INVITE: string;
};
};
config: {

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

@ -3,7 +3,7 @@ import { prisma } from '../database';
import { env } from '../config/env';
export type AuditSeverity = 'INFO' | 'WARN' | 'ERROR';
export type AuditCategory = 'SYSTEM' | 'BOOT' | 'VOICE' | 'PERMISSION' | 'INVITE' | 'MIMIC';
export type AuditCategory = 'SYSTEM' | 'BOOT' | 'VOICE' | 'PERMISSION' | 'MIMIC';
export interface AuditLogPayload {
category: AuditCategory;

View File

@ -0,0 +1,72 @@
import { Guild, GuildMember, PermissionFlagsBits } from 'discord.js';
import { prisma } from '../database';
import { logger } from '../utils/logger';
import { auditLogService } from './AuditLogService';
export class AutoRoleService {
/**
* .
*/
async getConfig(guildId: string) {
return prisma.autoRoleConfig.findUnique({
where: { guildId },
});
}
/**
* .
*/
async updateConfig(guildId: string, data: {
userRoleIds?: string[];
botRoleIds?: string[];
isEnabled?: boolean;
botEnabled?: boolean;
}) {
return prisma.autoRoleConfig.upsert({
where: { guildId },
create: {
guildId,
...data,
},
update: data,
});
}
/**
* /.
*/
async setEnabled(guildId: string, enabled: boolean) {
return this.updateConfig(guildId, { isEnabled: enabled });
}
/**
* .
*/
async handleMemberJoin(member: GuildMember) {
const config = await this.getConfig(member.guild.id);
if (!config) return;
const isBot = member.user.bot;
const isEnabled = isBot ? config.botEnabled : config.isEnabled;
const roleIds = isBot ? config.botRoleIds : config.userRoleIds;
if (!isEnabled || roleIds.length === 0) return;
try {
await member.roles.add(roleIds, 'Kord Auto-Role');
logger.info(`[AutoRole] Added roles to ${member.user.tag} in ${member.guild.name}`);
} catch (error) {
logger.error(`[AutoRole] Failed to add roles to ${member.user.tag} in ${member.guild.name}`, error);
// 권한 문제인 경우 감사 로그에 기록
await auditLogService.log(member.guild, {
category: 'PERMISSION',
severity: 'WARN',
title: 'Auto-Role Failure',
description: `Failed to assign roles to ${member.user.toString()} automatically. Please check the bot's permission and role hierarchy.`
}).catch(() => {});
}
}
}
export const autoRoleService = new AutoRoleService();

View File

@ -161,7 +161,7 @@ export class EventService {
const diff = event.startsAt.getTime() - now.getTime();
const dueOffsets = event.reminderOffsets.filter(offset =>
const dueOffsets = event.reminderOffsets.filter((offset: number) =>
offset > 0 &&
!event.sentReminderOffsets.includes(offset) &&
diff <= offset * 60 * 1000 &&

View File

@ -1,90 +0,0 @@
import { Client, Guild, Invite, GuildMember } from 'discord.js';
import { prisma } from '../database';
import { logger } from '../utils/logger';
export class InviteService {
/** In-process invite snapshot per guild for join attribution. */
private static readonly inviteCache = new Map<string, string>();
public static async cacheAllInvites(client: Client) {
for (const [, guild] of client.guilds.cache) {
await this.cacheGuildInvites(guild);
}
logger.info('InviteMaster: Finished caching all invites.');
}
public static async cacheGuildInvites(guild: Guild) {
try {
const invites = await guild.invites.fetch();
const inviteData = invites.map(inv => ({
code: inv.code,
uses: inv.uses || 0
}));
this.inviteCache.set(guild.id, JSON.stringify(inviteData));
} catch (error) {
logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error);
}
}
public static async handleInviteCreate(invite: Invite) {
if (!invite.guild) return;
logger.debug(`InviteMaster: New invite created: ${invite.code}`);
await this.cacheGuildInvites(invite.guild as Guild);
}
public static async handleInviteDelete(invite: Invite) {
if (!invite.guild) return;
logger.debug(`InviteMaster: Invite deleted: ${invite.code}`);
await this.cacheGuildInvites(invite.guild as Guild);
}
public static async handleMemberAdd(member: GuildMember) {
const guild = member.guild;
try {
// Fetch current active invites
const newInvites = await guild.invites.fetch();
const cachedData = this.inviteCache.get(guild.id);
let usedInvite: Invite | undefined;
if (cachedData) {
const cachedInvites: { code: string, uses: number }[] = JSON.parse(cachedData);
// Find the invite where 'uses' has increased
usedInvite = newInvites.find(inv => {
const cached = cachedInvites.find(c => c.code === inv.code);
return cached ? (inv.uses || 0) > cached.uses : false;
});
}
// Update the cache immediately to account for this new join
await this.cacheGuildInvites(guild);
if (usedInvite) {
logger.info(`InviteMaster: ${member.user.tag} joined using invite ${usedInvite.code}`);
// Check DB for mapped role
const inviteRole = await prisma.inviteRole.findFirst({
where: {
guildId: guild.id,
inviteCode: usedInvite.code
}
});
if (inviteRole) {
const role = guild.roles.cache.get(inviteRole.roleId);
if (role) {
await member.roles.add(role);
logger.info(`InviteMaster: Assigned role ${role.name} to ${member.user.tag}`);
} else {
logger.warn(`InviteMaster: Role ${inviteRole.roleId} mapped to invite ${usedInvite.code} not found.`);
}
}
} else {
logger.info(`InviteMaster: ${member.user.tag} joined but invite could not be determined (ex: Vanity URL).`);
}
} catch (error) {
logger.error(`InviteMaster: Failed to handle member add tracking:`, error);
}
}
}

View File

@ -88,7 +88,7 @@ const FEATURE_DEFINITIONS: FeatureDefinition[] = [
],
resolveChannelIds: async (guildId) => {
const generators = await prisma.voiceGenerator.findMany({ where: { guildId } });
return generators.map((g) => g.channelId);
return generators.map((g: { channelId: string }) => g.channelId);
},
},
@ -103,28 +103,13 @@ const FEATURE_DEFINITIONS: FeatureDefinition[] = [
],
resolveChannelIds: async (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);
},
},
// ── 5. 초대 추적 ──
{
featureKey: 'INVITE_TRACKING',
scope: 'guild',
permissions: [PermissionFlagsBits.ManageGuild],
},
// ── 6. 역할 자동 부여 (초대 연동) - 계층 검사 ──
{
featureKey: 'INVITE_ROLE_HIERARCHY',
scope: 'hierarchy',
resolveTargetRoleIds: async (guildId) => {
const inviteRoles = await prisma.inviteRole.findMany({ where: { guildId } });
return inviteRoles.map((ir) => ir.roleId);
},
},
// ── 7. 메시지 흉내 (Mimic) ──
// ── 5. 메시지 흉내 (Mimic) ──
{
featureKey: 'MIMIC_WEBHOOK',
scope: 'guild',

View File

@ -1,6 +1,8 @@
import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } from 'discord.js';
import { prisma } from '../database';
import { logger } from '../utils/logger';
import { ErrorDefs } from '../errors/ErrorCodes';
import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n';
import { getContextLocale } from '../i18n/localeHelper';
@ -16,6 +18,7 @@ export class VoiceService {
const guild = client.guilds.cache.get(temp.guildId) || await client.guilds.fetch(temp.guildId).catch(() => null);
if (!guild) continue;
const channel = guild.channels.cache.get(temp.channelId) || await guild.channels.fetch(temp.channelId).catch(() => null);
if (!channel || channel.type !== ChannelType.GuildVoice) {
@ -45,6 +48,7 @@ export class VoiceService {
}
}
logger.info('VoiceService: Channel synchronization complete.');
}
public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
const member = newState.member;

View File

@ -1,6 +1,8 @@
import { TextChannel, WebhookClient } from 'discord.js';
import { logger } from '../utils/logger';
export class WebhookService {
private static readonly MAX_WEBHOOKS = 10;
private static readonly WEBHOOK_NAME = 'Kord Mimic Webhook';
@ -16,6 +18,7 @@ export class WebhookService {
const cached = this.webhookCache.get(channel.id);
if (cached && now < cached.expiresAt) {
return new WebhookClient({ id: cached.id, token: cached.token });
}
// 2. Fetch from Discord API
@ -48,6 +51,7 @@ export class WebhookService {
id: kordWebhook.id,
token: kordWebhook.token,
expiresAt: Date.now() + this.WEBHOOK_CACHE_TTL_MS,
});
return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token });
}

View File

@ -37,6 +37,13 @@ ensureLogDir(logDir);
log4js.configure({
appenders: {
console: {
type: 'stdout',
layout: {
type: 'pattern',
pattern: '%[[%p]%] %m',
},
},
file: {
type: 'dateFile',
filename: resolve(logDir, 'kord.log'),
@ -50,7 +57,7 @@ log4js.configure({
},
},
categories: {
default: { appenders: ['file'], level },
default: { appenders: ['console', 'file'], level },
},
});

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
"@emnapi/core@npm:^1.4.3":
version: 1.9.1
resolution: "@emnapi/core@npm:1.9.1"
version: 1.9.2
resolution: "@emnapi/core@npm:1.9.2"
dependencies:
"@emnapi/wasi-threads": "npm:1.2.0"
"@emnapi/wasi-threads": "npm:1.2.1"
tslib: "npm:^2.4.0"
checksum: 10c0/00e7a99a2bc3ad908ca8272ba861a934da87dffa8797a41316c4a3b571a1e4d2743e2fa14b1a0f131fa4a3c2018ddb601cd2a8cb7f574fa940af696df3c2fe8d
checksum: 10c0/5500393f953951bad0768fafaa9191f2d938956b20c6d6a79e5ab696a613a25ce6ad23422bc18e86e6ce8deb147619d8d0d7d413a69f84adc01a6633cc353cd9
languageName: node
linkType: hard
"@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.7.0":
version: 1.9.1
resolution: "@emnapi/runtime@npm:1.9.1"
version: 1.9.2
resolution: "@emnapi/runtime@npm:1.9.2"
dependencies:
tslib: "npm:^2.4.0"
checksum: 10c0/750edca117e0363ab2de10622f8ee60e57d8690c2f29c49704813da5cd627c641798d7f3cb0d953c62fdc71688e02e333ddbf2c1204f38b47e3e40657332a6f5
checksum: 10c0/61c3a59e0c36784558b8d58eb02bd04815aa5fb0dbfbaf84d1b3050a78aa0cc63ea129ae806bd1e48062bfeb7fc36eb0e5431740d62f64ea51bdf426404b8caa
languageName: node
linkType: hard
"@emnapi/wasi-threads@npm:1.2.0":
version: 1.2.0
resolution: "@emnapi/wasi-threads@npm:1.2.0"
"@emnapi/wasi-threads@npm:1.2.1":
version: 1.2.1
resolution: "@emnapi/wasi-threads@npm:1.2.1"
dependencies:
tslib: "npm:^2.4.0"
checksum: 10c0/1e3724b5814b06c14782fda87eee9b9aa68af01576c81ffeaefdf621ddb74386e419d5b3b1027b6a8172397729d95a92f814fc4b8d3c224376428faa07a6a01a
checksum: 10c0/32fcfa81ab396533b2ec1f4082b1ff779a05d9c836bbbd3f4398405b0e6814c0d9503b7993130e37bc6941dbc1ded49f55e9700ae9ca4e803bab2b5bc5deb331
languageName: node
linkType: hard
@ -1563,7 +1563,7 @@ __metadata:
languageName: node
linkType: hard
"@prisma/client@npm:7.6.0":
"@prisma/client@npm:^7.6.0":
version: 7.6.0
resolution: "@prisma/client@npm:7.6.0"
dependencies:
@ -1580,7 +1580,7 @@ __metadata:
languageName: node
linkType: hard
"@prisma/config@npm:7.6.0":
"@prisma/config@npm:7.6.0, @prisma/config@npm:^7.6.0":
version: 7.6.0
resolution: "@prisma/config@npm:7.6.0"
dependencies:
@ -1872,9 +1872,9 @@ __metadata:
linkType: hard
"@sinclair/typebox@npm:^0.34.0":
version: 0.34.48
resolution: "@sinclair/typebox@npm:0.34.48"
checksum: 10c0/e09f26d8ad471a07ee64004eea7c4ec185349a1f61c03e87e71ea33cbe98e97959940076c2e52968a955ffd4c215bf5ba7035e77079511aac7935f25e989e29d
version: 0.34.49
resolution: "@sinclair/typebox@npm:0.34.49"
checksum: 10c0/16b7d87f039a49b68c10bb4cdcae2ce5242b2472228851fd6483731616aba4ef977690aa517b230a8d20da8185bb416eb34e326f30568b3963c1cf26b05d1ad8
languageName: node
linkType: hard
@ -2220,137 +2220,137 @@ __metadata:
linkType: hard
"@typescript-eslint/eslint-plugin@npm:^8.57.2":
version: 8.57.2
resolution: "@typescript-eslint/eslint-plugin@npm:8.57.2"
version: 8.58.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.58.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.57.2"
"@typescript-eslint/type-utils": "npm:8.57.2"
"@typescript-eslint/utils": "npm:8.57.2"
"@typescript-eslint/visitor-keys": "npm:8.57.2"
"@typescript-eslint/scope-manager": "npm:8.58.0"
"@typescript-eslint/type-utils": "npm:8.58.0"
"@typescript-eslint/utils": "npm:8.58.0"
"@typescript-eslint/visitor-keys": "npm:8.58.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.57.2
"@typescript-eslint/parser": ^8.58.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/92f3a45f6c2104cef5294bfba972c475b1d3fafb6070efa1178b38cb951e7dfbaf89eae50bfd95f4a476fe51783e218b115bd7cbc09fc9bc7c0ca6c5233861d2
typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/ac45c30f6ba9e188a01144708aa845e7ee8bb8a4d4f9aa6d2dce7784852d0821d42b031fee6832069935c3b885feff6d4014e30145b99693d25d7f563266a9f8
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:^8.57.2":
version: 8.57.2
resolution: "@typescript-eslint/parser@npm:8.57.2"
version: 8.58.0
resolution: "@typescript-eslint/parser@npm:8.58.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.57.2"
"@typescript-eslint/types": "npm:8.57.2"
"@typescript-eslint/typescript-estree": "npm:8.57.2"
"@typescript-eslint/visitor-keys": "npm:8.57.2"
"@typescript-eslint/scope-manager": "npm:8.58.0"
"@typescript-eslint/types": "npm:8.58.0"
"@typescript-eslint/typescript-estree": "npm:8.58.0"
"@typescript-eslint/visitor-keys": "npm:8.58.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/afd8a30bd42ac56b212f3182d1b60e4556542eb22147b5b7a9a606d3c79ee35e596baf0bd7672d7e236472d246efc86e06265a46be26150ac12b05e4c45d16a6
typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/56c7ec21675cec4730760bfa37c29e42e80b4d6444e2beca55fad9ef53731392270d142797482ea798405be0d7e28ec6c9c16a1ee2ee1c94f73d3bf0ed29763c
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.57.2":
version: 8.57.2
resolution: "@typescript-eslint/project-service@npm:8.57.2"
"@typescript-eslint/project-service@npm:8.58.0":
version: 8.58.0
resolution: "@typescript-eslint/project-service@npm:8.58.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.57.2"
"@typescript-eslint/types": "npm:^8.57.2"
"@typescript-eslint/tsconfig-utils": "npm:^8.58.0"
"@typescript-eslint/types": "npm:^8.58.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/f84e3165b0a214318d4bc119018b87c044170d7638945e84bd4cee2d752b62c1797ce722ca1161cd06f48512d0115ef75500e6c8fc01005ad4bb39fb48dd77bf
typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/e6d0cb2f7708ccb31a2ff9eb35817d4999c26e1f1cd3c607539e21d0c73a234daa77c73ee1163bc4e8b139252d619823c444759f1ddabdd138cab4885e9c9794
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.57.2":
version: 8.57.2
resolution: "@typescript-eslint/scope-manager@npm:8.57.2"
"@typescript-eslint/scope-manager@npm:8.58.0":
version: 8.58.0
resolution: "@typescript-eslint/scope-manager@npm:8.58.0"
dependencies:
"@typescript-eslint/types": "npm:8.57.2"
"@typescript-eslint/visitor-keys": "npm:8.57.2"
checksum: 10c0/532b1a97a5c2fce51400fa1a94e09615b4df84ce1f2d107206a3f3935074cada396a3e30f155582a698981832868e1afea1641ff779ad9456fdc94169b7def64
"@typescript-eslint/types": "npm:8.58.0"
"@typescript-eslint/visitor-keys": "npm:8.58.0"
checksum: 10c0/bd5c16780f22d62359af0f69909f38a15fa3c55e609124a7cd5c2a04322fe41e586d81066f3ad1dcc3c1eff24dbcb48b78d099626d611fbd680c20c005d48f1d
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.57.2, @typescript-eslint/tsconfig-utils@npm:^8.57.2":
version: 8.57.2
resolution: "@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.58.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.58.0"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/199dad2d96efc88ce94f5f3e12e97205537bf7a7152e56ef1d84dfbe7bd1babebea9b9f396c01b6c447505a4eb02c1cbbd2c28828c587b51b41b15d017a11d2f
typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/0a07fe1a28b2513e625882bc8d4c4e0c5a105cdbcb987beae12fc66dbe71dc9638013e4d1fa8ad10d828a2acd5e3fed987c189c00d41fed0e880009f99adf1b2
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.57.2":
version: 8.57.2
resolution: "@typescript-eslint/type-utils@npm:8.57.2"
"@typescript-eslint/type-utils@npm:8.58.0":
version: 8.58.0
resolution: "@typescript-eslint/type-utils@npm:8.58.0"
dependencies:
"@typescript-eslint/types": "npm:8.57.2"
"@typescript-eslint/typescript-estree": "npm:8.57.2"
"@typescript-eslint/utils": "npm:8.57.2"
"@typescript-eslint/types": "npm:8.58.0"
"@typescript-eslint/typescript-estree": "npm:8.58.0"
"@typescript-eslint/utils": "npm:8.58.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/9c479cd0e809d26b7da7b31e830520bc016aaf528bc10a8b8279374808cb76a27f1b4adc77c84156417dc70f6a9e8604f47717b555a27293da2b9b5cfda70411
typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/1223733d41f8463be92ef1ad048d546f9663152212b22dc968abbd9f8e4486bd4082e16baa51d2d281e0d4815563bc4b1ecf01684e2940b7897ba17aa26d1196
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.57.2, @typescript-eslint/types@npm:^8.57.2":
version: 8.57.2
resolution: "@typescript-eslint/types@npm:8.57.2"
checksum: 10c0/3cd87dd77d28b3ac2fed56a17909b0d11633628d4d733aa148dfd7af72e2cc3ec0e6114b72fac0ff538e8a47e907b4b10dab4095170ae1bd73719ef0b8eaf2e7
"@typescript-eslint/types@npm:8.58.0, @typescript-eslint/types@npm:^8.58.0":
version: 8.58.0
resolution: "@typescript-eslint/types@npm:8.58.0"
checksum: 10c0/f2fe1321758a04591c20d77caba956ae76b77cff0b976a0224b37077d80b1ebd826874d15ec79c3a3b7d57ee5679e5d10756db1b082bde3d51addbd3a8431d38
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.57.2":
version: 8.57.2
resolution: "@typescript-eslint/typescript-estree@npm:8.57.2"
"@typescript-eslint/typescript-estree@npm:8.58.0":
version: 8.58.0
resolution: "@typescript-eslint/typescript-estree@npm:8.58.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.57.2"
"@typescript-eslint/tsconfig-utils": "npm:8.57.2"
"@typescript-eslint/types": "npm:8.57.2"
"@typescript-eslint/visitor-keys": "npm:8.57.2"
"@typescript-eslint/project-service": "npm:8.58.0"
"@typescript-eslint/tsconfig-utils": "npm:8.58.0"
"@typescript-eslint/types": "npm:8.58.0"
"@typescript-eslint/visitor-keys": "npm:8.58.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
tinyglobby: "npm:^0.2.15"
ts-api-utils: "npm:^2.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/2c5d143f0abbafd07a45f0b956aab5d6487b27f74fe93bee93e0a3f8edc8913f1522faf8d7d5215f3809a8d12f5729910ea522156552f2481b66e6d05ab311ae
typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/a8cb94cb765b27740a54f9b5378bd8f0dc49e301ceed99a0791dc9d1f61c2a54e3212f7ed9120c8c2df80104ad3117150cf5e7fe8a0b7eec3ed04969a79b103e
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.57.2":
version: 8.57.2
resolution: "@typescript-eslint/utils@npm:8.57.2"
"@typescript-eslint/utils@npm:8.58.0":
version: 8.58.0
resolution: "@typescript-eslint/utils@npm:8.58.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.57.2"
"@typescript-eslint/types": "npm:8.57.2"
"@typescript-eslint/typescript-estree": "npm:8.57.2"
"@typescript-eslint/scope-manager": "npm:8.58.0"
"@typescript-eslint/types": "npm:8.58.0"
"@typescript-eslint/typescript-estree": "npm:8.58.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/5771f3d4206004cc817a6556a472926b4c1c885dc448049c10ffab1d5aac7bd59450a391fb57ce8ef31a8367e9c8ddb3bc9370c4e83fc8b61f50fd5189390e8f
typescript: ">=4.8.4 <6.1.0"
checksum: 10c0/457e01a6e6d954dbfe13c49ece3cf8a55e5d8cf19ea9ae7086c0e205d89e3cdbb91153062ab440d2e78ad3f077b174adc42bfb1b6fc24299020a0733e7f9c11c
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.57.2":
version: 8.57.2
resolution: "@typescript-eslint/visitor-keys@npm:8.57.2"
"@typescript-eslint/visitor-keys@npm:8.58.0":
version: 8.58.0
resolution: "@typescript-eslint/visitor-keys@npm:8.58.0"
dependencies:
"@typescript-eslint/types": "npm:8.57.2"
"@typescript-eslint/types": "npm:8.58.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10c0/8ceb8c228bf97b3e4b343bf6e42a91998d2522f459eb6b53c6bfad4898a9df74295660893dee6b698bdbbda537e968bfc13a3c56fc341089ebfba13db766a574
checksum: 10c0/75f3c9c097a308cc6450822a0f81d44c8b79b524e99dd2c41ded347b12f148ab3bd459ce9cc6bd00f8f0725c5831baab6d2561596ead3394ab76dddbeb32cce1
languageName: node
linkType: hard
@ -2755,11 +2755,11 @@ __metadata:
linkType: hard
"baseline-browser-mapping@npm:^2.9.0":
version: 2.10.11
resolution: "baseline-browser-mapping@npm:2.10.11"
version: 2.10.12
resolution: "baseline-browser-mapping@npm:2.10.12"
bin:
baseline-browser-mapping: dist/cli.cjs
checksum: 10c0/6ae44b653c26de8ea7b75a109c0771c95c9676c32a11aff25f7c10b2ddeb864d2c387243a0ec083dffe3b76a7aacf2d777fa7e5a6df16e3a2624c1573e116285
checksum: 10c0/391d354240160546c8248317698b61f21f287cc6444766414c2d299a8880045e605ed97e8d8cd198a0b9dfaa4e73c2fa765bbef089474533a904733b1dc9a363
languageName: node
linkType: hard
@ -2775,25 +2775,25 @@ __metadata:
linkType: hard
"brace-expansion@npm:^1.1.7":
version: 1.1.12
resolution: "brace-expansion@npm:1.1.12"
version: 1.1.13
resolution: "brace-expansion@npm:1.1.13"
dependencies:
balanced-match: "npm:^1.0.0"
concat-map: "npm:0.0.1"
checksum: 10c0/975fecac2bb7758c062c20d0b3b6288c7cc895219ee25f0a64a9de662dbac981ff0b6e89909c3897c1f84fa353113a721923afdec5f8b2350255b097f12b1f73
checksum: 10c0/384c61bb329b6adfdcc0cbbdd108dc19fb5f3e84ae15a02a74f94c6c791b5a9b035aae73b2a51929a8a478e2f0f212a771eb6a8b5b514cccfb8d0c9f2ce8cbd8
languageName: node
linkType: hard
"brace-expansion@npm:^2.0.2":
version: 2.0.2
resolution: "brace-expansion@npm:2.0.2"
version: 2.0.3
resolution: "brace-expansion@npm:2.0.3"
dependencies:
balanced-match: "npm:^1.0.0"
checksum: 10c0/6d117a4c793488af86b83172deb6af143e94c17bc53b0b3cec259733923b4ca84679d506ac261f4ba3c7ed37c46018e2ff442f9ce453af8643ecd64f4a54e6cf
checksum: 10c0/468436c9b2fa6f9e64d0cff8784b21300677571a7196e258593e95e7c3db9973a80fbafdb0f01404d5d298a04dc666eae1fc3c9052e2edbb9f2510541deeddfe
languageName: node
linkType: hard
"brace-expansion@npm:^5.0.2":
"brace-expansion@npm:^5.0.5":
version: 5.0.5
resolution: "brace-expansion@npm:5.0.5"
dependencies:
@ -2907,9 +2907,9 @@ __metadata:
linkType: hard
"caniuse-lite@npm:^1.0.30001759":
version: 1.0.30001781
resolution: "caniuse-lite@npm:1.0.30001781"
checksum: 10c0/79e77d8759a55e90f0f5db96ab9e7925c7b2e3021f77852e647e45f64f7dc701954174188438e84b810824afc16d706c64a38f20f9c1ed9ac174b6362d33325f
version: 1.0.30001782
resolution: "caniuse-lite@npm:1.0.30001782"
checksum: 10c0/f11685de4ce1f0bc16d385fc0a07b0877da0b14af8bf510cee6a3cdfe9da1602360e1f11320e92d4f5d63cd6bec8b43539de25ee78ff94bdb7ec0fa3cce5200c
languageName: node
linkType: hard
@ -3201,14 +3201,7 @@ __metadata:
languageName: node
linkType: hard
"discord-api-types@npm:^0.38.1, discord-api-types@npm:^0.38.33, discord-api-types@npm:^0.38.40":
version: 0.38.42
resolution: "discord-api-types@npm:0.38.42"
checksum: 10c0/e902e50b7d10f788a7f993429fffce1fbb104b0ccd4ef1506c4c3680cb551b7562e1345d1bb4764e70a2a5325b1c092a4fad89e2dd58dab1cdeebac461b66e55
languageName: node
linkType: hard
"discord-api-types@npm:^0.38.41":
"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.43
resolution: "discord-api-types@npm:0.38.43"
checksum: 10c0/8d617f63e415f0a238fddd8ae3cb9f88164b884ce321e4456f4d1c8329e5c8ff2132a2423c78d1fced773d845ece9570b586f49a383f095b0ca3734844cfcaf0
@ -3268,9 +3261,9 @@ __metadata:
linkType: hard
"electron-to-chromium@npm:^1.5.263":
version: 1.5.326
resolution: "electron-to-chromium@npm:1.5.326"
checksum: 10c0/8a773ac36b36c5e62c3704a27adbe4d2a72961a8d46782c96e2515a13fbcc6f9e850b1e478482775ee3874ac4b42cd5c32870b2faf4c8e1c7569e172e310c314
version: 1.5.329
resolution: "electron-to-chromium@npm:1.5.329"
checksum: 10c0/a275d7dd7ef26b98d304d37831684614b575d91d5186d3764e7c10114677ba84f4b9ee54a7ef326f63f2dbb2ca883582e3ef9925d9aee8562e1982fa42c94c43
languageName: node
linkType: hard
@ -4797,7 +4790,8 @@ __metadata:
"@discordjs/opus": "npm:^0.10.0"
"@discordjs/voice": "npm:^0.19.2"
"@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/node": "npm:^25.5.0"
"@types/pg": "npm:^8.20.0"
@ -4812,7 +4806,7 @@ __metadata:
pg: "npm:^8.20.0"
prettier: "npm:^3.8.1"
prism-media: "npm:^1.3.5"
prisma: "npm:7.6.0"
prisma: "npm:^7.6.0"
sharp: "npm:^0.34.5"
ts-jest: "npm:^29.4.6"
tsx: "npm:^4.21.0"
@ -4912,9 +4906,9 @@ __metadata:
linkType: hard
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1":
version: 11.2.7
resolution: "lru-cache@npm:11.2.7"
checksum: 10c0/549cdb59488baa617135fc12159cafb1a97f91079f35093bb3bcad72e849fc64ace636d244212c181dfdf1a99bbfa90757ff303f98561958ee4d0f885d9bd5f7
version: 11.3.0
resolution: "lru-cache@npm:11.3.0"
checksum: 10c0/f50d32064c07b17390e7bf89fa09fcf05c19018d8cb03359a04dad3e16e4213812958af32eb0e29a92239a1f13631b58c26c007367f99946defcf4e495c1c123
languageName: node
linkType: hard
@ -5017,11 +5011,11 @@ __metadata:
linkType: hard
"minimatch@npm:^10.2.2, minimatch@npm:^10.2.4":
version: 10.2.4
resolution: "minimatch@npm:10.2.4"
version: 10.2.5
resolution: "minimatch@npm:10.2.5"
dependencies:
brace-expansion: "npm:^5.0.2"
checksum: 10c0/35f3dfb7b99b51efd46afd378486889f590e7efb10e0f6a10ba6800428cf65c9a8dedb74427d0570b318d749b543dc4e85f06d46d2858bc8cac7e1eb49a95945
brace-expansion: "npm:^5.0.5"
checksum: 10c0/6bb058bd6324104b9ec2f763476a35386d05079c1f5fe4fbf1f324a25237cd4534d6813ecd71f48208f4e635c1221899bef94c3c89f7df55698fe373aaae20fd
languageName: node
linkType: hard
@ -5740,7 +5734,7 @@ __metadata:
languageName: node
linkType: hard
"prisma@npm:7.6.0":
"prisma@npm:^7.6.0":
version: 7.6.0
resolution: "prisma@npm:7.6.0"
dependencies:
@ -6366,7 +6360,7 @@ __metadata:
languageName: node
linkType: hard
"ts-api-utils@npm:^2.4.0":
"ts-api-utils@npm:^2.5.0":
version: 2.5.0
resolution: "ts-api-utils@npm:2.5.0"
peerDependencies: