Compare commits

..

No commits in common. "2a8b6d41dce4ade65cbc5de16cfe4f293bc884af" and "c10822ea87f508d1132fbd7c18db792eec81762d" have entirely different histories.

21 changed files with 6990 additions and 13698 deletions

View File

@ -6,5 +6,6 @@ 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"
## NOTE # Redis Configuration
## This project does not use Redis. REDIS_HOST="localhost"
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 --immutable RUN corepack enable && yarn install
# Generate Prisma Client # Generate Prisma Client
COPY prisma ./prisma/ COPY prisma ./prisma/
@ -17,8 +17,9 @@ FROM node:20-alpine AS runner
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./ COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn COPY .yarn ./.yarn
RUN corepack enable && yarn install --immutable --production RUN corepack enable && yarn install
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"] CMD ["yarn", "node", "dist/index.js"]

View File

@ -1,242 +0,0 @@
# 데이터베이스 테이블 구조
이 문서는 [Prisma 스키마](../prisma/schema.prisma)를 기준으로 한 **PostgreSQL** 테이블 구조 요약입니다.
## 개요
| 항목 | 값 |
|------|-----|
| DB | PostgreSQL |
| ORM | Prisma (`prisma-client-js`) |
| 연결 | `DATABASE_URL` 환경 변수 |
## 열거형 (Enum)
### `SubscriptionTier`
구독 단계.
| 값 | 설명 |
|----|------|
| `FREE` | 기본 |
| `STANDARD` | 스탠다드 |
| `PRO` | 프로 |
| `PREMIUM` | 프리미엄 |
### `DeleteCondition`
임시 음성 채널 삭제 조건.
| 값 | 설명 |
|----|------|
| `OWNER_LEAVE` | 소유자 퇴장 시 |
| `EMPTY` | 비었을 때 (기본) |
### `EventStatus`
길드 이벤트 상태.
| 값 | 설명 |
|----|------|
| `SCHEDULED` | 예정 (기본) |
| `CANCELLED` | 취소 |
| `COMPLETED` | 완료 |
---
## 테이블 목록
### `GuildConfig`
디스코드 길드별 봇 설정.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `guildId` | `String` | PK |
| `prefix` | `String` | 기본 `!` |
| `mimicEnabled` | `Boolean` | 기본 `false` |
| `bigEmojiEnabled` | `Boolean` | 기본 `false` |
| `locale` | `String?` | nullable |
| `createdAt` | `DateTime` | 생성 시각 |
| `updatedAt` | `DateTime` | 자동 갱신 |
---
### `InviteRole`
길드·초대 코드별 역할 매핑.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `id` | `String` | PK, UUID |
| `guildId` | `String` | |
| `inviteCode` | `String` | |
| `roleId` | `String` | |
| `createdAt` | `DateTime` | |
**유니크:** `(guildId, inviteCode)`
---
### `UserSubscription`
사용자 구독 정보.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `userId` | `String` | PK |
| `tier` | `SubscriptionTier` | 기본 `FREE` |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
**관계:** `GuildOwnership[]` (1:N)
---
### `GuildOwnership`
구독 사용자가 소유하는 길드.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `guildId` | `String` | PK |
| `ownerId` | `String` | FK → `UserSubscription.userId`, `ON DELETE CASCADE` |
| `createdAt` | `DateTime` | |
**인덱스:** `ownerId`
---
### `VoiceGenerator`
음성 채널 생성기(부모 채널) 설정.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `channelId` | `String` | PK |
| `guildId` | `String` | |
| `categoryId` | `String?` | |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
**인덱스:** `guildId`
---
### `TempVoiceChannel`
생성된 임시 음성 채널.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `channelId` | `String` | PK |
| `guildId` | `String` | |
| `ownerId` | `String` | |
| `deleteWhen` | `DeleteCondition` | 기본 `EMPTY` |
| `createdAt` | `DateTime` | |
**인덱스:** `guildId`, `ownerId`
---
### `UserVoiceProfile`
길드별 사용자 음성 프로필(표시 이름·인원 제한 등).
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `userId` | `String` | 복합 PK |
| `guildId` | `String` | 복합 PK |
| `customName` | `String?` | |
| `userLimit` | `Int?` | |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
**PK:** `(userId, guildId)`
---
### `UserLocale`
사용자별 로케일.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `userId` | `String` | PK |
| `locale` | `String` | |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
---
### `AuditChannel`
감사 로그 전달 채널·비활성 카테고리.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `guildId` | `String` | PK |
| `channelId` | `String` | |
| `disabledCategories` | `String[]` | 기본 `["BOOT", "SYSTEM"]` |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
---
### `VoiceGuildConfig`
길드 단위 음성(임시 채널) 기본 설정.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `guildId` | `String` | PK |
| `defaultNameTemplate` | `String` | 기본 `{{username}}'s Room` |
| `defaultUserLimit` | `Int` | 기본 `0` |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
---
### `GuildEvent`
길드 일정/이벤트.
| 컬럼 | 타입 | 제약 / 기본값 |
|------|------|----------------|
| `id` | `String` | PK, UUID |
| `guildId` | `String` | |
| `title` | `String` | |
| `description` | `String?` | |
| `startsAt` | `DateTime` | |
| `timezone` | `String` | 기본 `Asia/Seoul` |
| `status` | `EventStatus` | 기본 `SCHEDULED` |
| `announcementChannelId` | `String?` | |
| `createdByUserId` | `String` | |
| `reminderEnabled` | `Boolean` | 기본 `true` |
| `reminderOffsets` | `Int[]` | 기본 `[]` |
| `sentReminderOffsets` | `Int[]` | 기본 `[]` |
| `remindedOneHour` | `Boolean` | 기본 `false` |
| `remindedTenMinutes` | `Boolean` | 기본 `false` |
| `startedAnnounced` | `Boolean` | 기본 `false` |
| `announcedAt` | `DateTime?` | |
| `createdAt` | `DateTime` | |
| `updatedAt` | `DateTime` | |
**인덱스:** `(guildId, startsAt)`, `(guildId, status)`
---
## 관계 요약
```mermaid
erDiagram
UserSubscription ||--o{ GuildOwnership : "userId"
```
- **UserSubscription** 1 — N **GuildOwnership** (`ownerId` → `userId`, 길드 삭제 시 소유권 행 CASCADE 삭제)
그 외 테이블은 Prisma 모델상 **명시적 `relation` 블록**이 없으며, `guildId` / `userId` / `channelId` 등이 애플리케이션 레벨에서 Discord ID로 연결됩니다.
## 스키마 변경 시
실제 DDL은 `prisma/migrations/` 아래 마이그레이션 SQL과 동기화됩니다. 구조를 바꾼 뒤에는 이 문서와 [schema.prisma](../prisma/schema.prisma)를 함께 맞추는 것이 좋습니다.

View File

@ -4,13 +4,14 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
## 1. 개요 (Overview) ## 1. 개요 (Overview)
**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다. **Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)와 Redis를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다.
## 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)
@ -58,7 +59,7 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
1. **빌드**: `yarn build` 1. **빌드**: `yarn build`
2. **실행**: `yarn start` 2. **실행**: `yarn start`
3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB)을 실행할 수 있습니다. 3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB, Redis)을 실행할 수 있습니다.
## 5. 기능 목록 (Feature List) ## 5. 기능 목록 (Feature List)

View File

@ -1,15 +1,6 @@
version: '3.8' version: '3.8'
services: services:
kord:
build: .
container_name: kord-bot
env_file:
- .env
depends_on:
- postgres
restart: unless-stopped
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: kord-postgres container_name: kord-postgres
@ -23,5 +14,15 @@ 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

File diff suppressed because it is too large Load Diff

View File

@ -5,8 +5,7 @@
"@discordjs/opus": "^0.10.0", "@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.2", "@discordjs/voice": "^0.19.2",
"@prisma/adapter-pg": "^7.6.0", "@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "7.6.0",
"@prisma/config": "^7.6.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
@ -25,7 +24,7 @@
"eslint": "^10.1.0", "eslint": "^10.1.0",
"jest": "^30.3.0", "jest": "^30.3.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prisma": "^7.6.0", "prisma": "7.6.0",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.6",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^6.0.2" "typescript": "^6.0.2"

View File

@ -1,9 +0,0 @@
-- CreateTable
CREATE TABLE "guild_payment" (
"id" TEXT NOT NULL,
"music" BOOLEAN NOT NULL DEFAULT false,
"minigame" BOOLEAN NOT NULL DEFAULT false,
"broadcast" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "guild_payment_pkey" PRIMARY KEY ("id")
);

View File

@ -1,6 +1,5 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
} }
datasource db { datasource db {
@ -141,73 +140,47 @@ enum EventStatus {
COMPLETED COMPLETED
} }
/// 길드별 유료 기능(특성) 플래그. 행이 없거나 해당 컬럼이 false면 미결제로 간주합니다. // ─── Mini Game System ───────────────────────────────────────────────────────
model GuildPayment {
id String @id
music Boolean @default(false)
minigame Boolean @default(false)
broadcast Boolean @default(false)
@@map("guild_payment")
}
// 서버별 미니게임 활성화 상태 관리
model MiniGameConfig { model MiniGameConfig {
id String @id @default(uuid()) id String @id @default(uuid())
guildId String guildId String
gameKey String gameKey String
enabled Boolean @default(false) enabled Boolean @default(false)
channelId String? channelId String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([guildId])
@@unique([guildId, gameKey]) @@unique([guildId, gameKey])
@@index([guildId])
} }
// 재련 - 유저 상태
model RefinementProfile { model RefinementProfile {
userId String userId String
guildId String guildId String
gold Int @default(1000) gold Int @default(1000)
weaponLevel Int @default(0) weaponLevel Int @default(0)
maxWeaponLevel Int @default(0) maxWeaponLevel Int @default(0)
durability Int @default(10) durability Int @default(10)
tryCount Int @default(0) tryCount Int @default(0)
successCount Int @default(0) successCount Int @default(0)
failCount Int @default(0) failCount Int @default(0)
destroyCount Int @default(0) destroyCount Int @default(0)
battleWin Int @default(0) battleWin Int @default(0)
battleLoss Int @default(0) battleLoss Int @default(0)
dailyBattleCount Int @default(0) dailyBattleCount Int @default(0)
isDisabled Boolean @default(false) lastBattleReset DateTime @default(now())
lastCheckIn DateTime? isDisabled Boolean @default(false)
lastBattleReset DateTime @default(now()) lastCheckIn DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@id([userId, guildId]) @@id([userId, guildId])
@@index([guildId, weaponLevel(sort: Desc)]) @@index([guildId, weaponLevel(sort: Desc)])
} }
model 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
@ -218,16 +191,41 @@ 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
}

65
src/cache/index.ts vendored
View File

@ -1,52 +1,23 @@
type CacheEntry = { value: string; expiresAtMs?: number }; import Redis from 'ioredis';
import { env } from '../config/env';
import { logger } from '../utils/logger';
const nowMs = () => Date.now(); export const redis = new Redis({
host: env.REDIS_HOST,
port: env.REDIS_PORT,
lazyConnect: true,
});
class LocalCache { redis.on('error', (err) => {
private store = new Map<string, CacheEntry>(); logger.error('Redis Error:', err);
});
private isExpired(entry: CacheEntry | undefined) { export const connectRedis = async () => {
return entry?.expiresAtMs !== undefined && entry.expiresAtMs <= nowMs(); try {
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,6 +5,8 @@ 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();
@ -27,6 +29,7 @@ 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,6 +11,8 @@ 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(),

View File

@ -6,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 { tryAcquireLock } from '../cache'; import { redis } from '../cache';
import { env } from '../config/env'; import { env } from '../config/env';
export default { export default {
@ -22,7 +22,7 @@ export default {
try { try {
const lockKey = 'commands:sync:lock'; const lockKey = 'commands:sync:lock';
// EX 300 = 5 minutes lock. Only one instance needs to do this per boot cycle. // EX 300 = 5 minutes lock. Only one instance needs to do this per boot cycle.
const acquired = await tryAcquireLock(lockKey, 300); 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()); const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON());

View File

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

View File

@ -1,83 +0,0 @@
/**
* `Command` ().
*
* `handlers/CommandLoader` `src/commands/`
* .
* `src/commands/your-command.ts` `setName` .
*
* {@link CommandTrait.Music} `guild_payment.music === true`
* ( ).
*/
import {
SlashCommandBuilder,
ChatInputCommandInteraction,
SlashCommandStringOption,
} from 'discord.js';
import { Command, CommandTrait } from './command';
import { SupportedLocale } from '../i18n';
class ExampleSlashCommand extends Command {
/** 음악 유료 특성 — DB `guild_payment.music` 플래그를 검사합니다. */
protected readonly trait = CommandTrait.Music;
/** 길드에서만 쓰도록 기본 가드 사용 */
protected guildOnly = true;
protected define() {
return (
new SlashCommandBuilder()
.setName('command_usage_demo')
.setDescription('Example: Command base class usage (not registered from this path).')
.setDescriptionLocalizations({
ko: '예시: Command 베이스 클래스 사용법 (이 경로에서는 등록되지 않음).',
})
// 서브커맨드/옵션은 기존과 같이 붙이면 됩니다.
.addStringOption((option: SlashCommandStringOption) =>
option
.setName('message')
.setDescription('Echo text')
.setRequired(false),
)
);
}
/**
* `beforeHandle` .
* `false` `handle` .
*/
protected async beforeHandle(
interaction: ChatInputCommandInteraction,
_locale: SupportedLocale,
): Promise<boolean> {
const msg = interaction.options.getString('message');
if (msg === 'block') {
await interaction.reply({ content: 'Blocked by beforeHandle.', ephemeral: true });
return false;
}
return true;
}
protected async handle(
interaction: ChatInputCommandInteraction,
locale: SupportedLocale,
): Promise<void> {
const message = interaction.options.getString('message');
const guildName = interaction.guild?.name ?? 'unknown';
const line =
message != null && message.length > 0
? `[${locale}] **${guildName}** — ${message}`
: `[${locale}] **${guildName}** — (no message option)`;
await interaction.reply({ content: line, ephemeral: true });
}
}
/**
* `commands/*.ts` :
*
* ```ts
* export default new ExampleSlashCommand().toModule();
* ```
*/
export default new ExampleSlashCommand().toModule();

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: number) => const dueOffsets = event.reminderOffsets.filter(offset =>
offset > 0 && offset > 0 &&
!event.sentReminderOffsets.includes(offset) && !event.sentReminderOffsets.includes(offset) &&
diff <= offset * 60 * 1000 && diff <= offset * 60 * 1000 &&

View File

@ -1,5 +1,5 @@
import { Client, Guild, Invite, GuildMember } from 'discord.js'; import { Client, Guild, Invite, GuildMember } from 'discord.js';
import { cache } from '../cache'; import { redis } 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 cache.set(`invites:${guild.id}`, JSON.stringify(inviteData)); await redis.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 cache.get(`invites:${guild.id}`); const cachedData = await redis.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: { channelId: string }) => g.channelId); return generators.map((g) => g.channelId);
}, },
}, },
@ -103,9 +103,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 return generators.map((g) => g.categoryId).filter((id): id is string => id !== null);
.map((g: { categoryId: string | null }) => g.categoryId)
.filter((id: string | null): id is string => id !== null);
}, },
}, },
@ -122,7 +120,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: { roleId: string }) => ir.roleId); return inviteRoles.map((ir) => 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 { tryAcquireLock } from '../cache'; import { redis } 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,7 +10,8 @@ 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';
const acquired = await tryAcquireLock(lockKey, 60); // NX = only set if not exists, EX = expire in 60s
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;
@ -54,8 +55,9 @@ export class VoiceService {
} }
} }
logger.info('VoiceService: Channel synchronization complete.'); logger.info('VoiceService: Channel synchronization complete.');
} catch (error) { } finally {
logger.error('VoiceService: Failed during channel synchronization', error); // Free the lock just in case, though EX ensures it doesn't hang forever
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 { cache } from '../cache'; import { redis } 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 cache.get(`webhook:${channel.id}`); const cachedData = await redis.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,11 +40,14 @@ export class WebhookService {
logger.info(`Created new webhook for channel ${channel.id}`); logger.info(`Created new webhook for channel ${channel.id}`);
} }
// 3. Save to cache (expire in 1 day to ensure token freshness) // 3. Save to Redis Cache (expire in 1 day to ensure token freshness)
if (kordWebhook.token) { if (kordWebhook.token) {
await cache.set(`webhook:${channel.id}`, JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }), { await redis.set(
exSeconds: 86400, `webhook:${channel.id}`,
}); 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 });
} }

11044
yarn.lock

File diff suppressed because it is too large Load Diff