diff --git a/.env.example b/.env.example index 38fabdc..fabe1a6 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,5 @@ DISCORD_CLIENT_ID=your_client_id_here # User/pass from docker-compose.yml DATABASE_URL="postgresql://kord:password@localhost:5432/kord_db?schema=public" -# Redis Configuration -REDIS_HOST="localhost" -REDIS_PORT=6379 \ No newline at end of file +## NOTE +## This project does not use Redis. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0c8104a..0d15e61 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 56ccfcd..66bd019 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,13 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입 ## 1. 개요 (Overview) -**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)와 Redis를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다. +**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다. ## 2. 요구사항 (Requirements) - **Runtime**: Node.js v20 이상 - **Package Manager**: Yarn v4 (Berry) - **Database**: PostgreSQL (Prisma 사용) -- **Cache**: Redis (다중 인스턴스 동기화 및 캐싱) - **Discord**: Bot Token 및 Client ID (Slash Command 등록용) ## 3. 테스트 방법 (Test Methods) @@ -59,7 +58,7 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입 1. **빌드**: `yarn build` 2. **실행**: `yarn start` -3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB, Redis)을 실행할 수 있습니다. +3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB)을 실행할 수 있습니다. ## 5. 기능 목록 (Feature List) diff --git a/docker-compose.yml b/docker-compose.yml index 96a7b81..e01f171 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 @@ -14,15 +23,5 @@ services: - postgres_data:/var/lib/postgresql/data restart: unless-stopped - redis: - image: redis:7-alpine - container_name: kord-redis - ports: - - "6379:6379" - volumes: - - redis_data:/data - restart: unless-stopped - volumes: postgres_data: - redis_data: diff --git a/package.json b/package.json index 90e3931..e613d05 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,7 @@ "dependencies": { "@prisma/client": "6.4.1", "discord.js": "^14.25.1", - "dotenv": "^17.3.1", - "ioredis": "^5.10.1" + "dotenv": "^17.3.1" }, "devDependencies": { "@types/jest": "^30.0.0", diff --git a/src/cache/index.ts b/src/cache/index.ts index 3f0517a..009f66a 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -1,23 +1,52 @@ -import Redis from 'ioredis'; -import { env } from '../config/env'; -import { logger } from '../utils/logger'; +type CacheEntry = { value: string; expiresAtMs?: number }; -export const redis = new Redis({ - host: env.REDIS_HOST, - port: env.REDIS_PORT, - lazyConnect: true, -}); +const nowMs = () => Date.now(); -redis.on('error', (err) => { - logger.error('Redis Error:', err); -}); +class LocalCache { + private store = new Map(); -export const connectRedis = async () => { - try { - await redis.connect(); - logger.info('Connected to Redis successfully.'); - } catch (error) { - logger.error('Failed to connect to Redis:', error); - process.exit(1); + private isExpired(entry: CacheEntry | undefined) { + return entry?.expiresAtMs !== undefined && entry.expiresAtMs <= nowMs(); } + + 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 { + const entry = this.getEntry(key); + return entry ? entry.value : null; + } + + async set(key: string, value: string, opts?: { exSeconds?: number; nx?: boolean }): Promise { + 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 { + 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 => { + return cache.set(`lock:${key}`, '1', { exSeconds: ttlSeconds, nx: true }); }; diff --git a/src/client/KordClient.ts b/src/client/KordClient.ts index 837e7af..135eeab 100644 --- a/src/client/KordClient.ts +++ b/src/client/KordClient.ts @@ -5,7 +5,6 @@ import { loadCommands } from '../handlers/CommandLoader'; import { loadEvents } from '../handlers/EventLoader'; import { handleGlobalExceptions } from '../utils/errorHandler'; import { connectDB } from '../database'; -import { connectRedis } from '../cache'; export class KordClient extends Client { public commands: Collection = new Collection(); @@ -28,7 +27,6 @@ export class KordClient extends Client { // Connect to external services await connectDB(); - await connectRedis(); // Load Handlers await loadCommands(this); diff --git a/src/config/env.ts b/src/config/env.ts index dbb2300..0c3a0a7 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -11,8 +11,6 @@ export const env = { DISCORD_TOKEN: process.env.DISCORD_TOKEN || '', DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID || '', 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_CATEGORY_ID: process.env.VOICE_CATEGORY_ID || '', INSTANCE_ID: generateInstanceId(), diff --git a/src/events/ready.ts b/src/events/ready.ts index 0ecf7c5..80d4fcd 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -6,7 +6,7 @@ import { VoiceService } from '../services/VoiceService'; import { PresenceService } from '../services/PresenceService'; import { EventService } from '../services/EventService'; import { auditLogService } from '../services/AuditLogService'; -import { redis } from '../cache'; +import { tryAcquireLock } from '../cache'; import { env } from '../config/env'; export default { @@ -22,7 +22,7 @@ export default { try { const lockKey = 'commands:sync:lock'; // EX 300 = 5 minutes lock. Only one instance needs to do this per boot cycle. - const acquired = await redis.set(lockKey, '1', 'EX', 300, 'NX'); + const acquired = await tryAcquireLock(lockKey, 300); if (acquired) { const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON()); diff --git a/src/services/InviteService.ts b/src/services/InviteService.ts index 6d4912e..42fd6c4 100644 --- a/src/services/InviteService.ts +++ b/src/services/InviteService.ts @@ -1,5 +1,5 @@ import { Client, Guild, Invite, GuildMember } from 'discord.js'; -import { redis } from '../cache'; +import { cache } from '../cache'; import { prisma } from '../database'; import { logger } from '../utils/logger'; @@ -18,7 +18,7 @@ export class InviteService { code: inv.code, uses: inv.uses || 0 })); - await redis.set(`invites:${guild.id}`, JSON.stringify(inviteData)); + await cache.set(`invites:${guild.id}`, JSON.stringify(inviteData)); } catch (error) { logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error); } @@ -41,7 +41,7 @@ export class InviteService { try { // Fetch current active invites const newInvites = await guild.invites.fetch(); - const cachedData = await redis.get(`invites:${guild.id}`); + const cachedData = await cache.get(`invites:${guild.id}`); let usedInvite: Invite | undefined; diff --git a/src/services/VoiceService.ts b/src/services/VoiceService.ts index e23a7e7..900f507 100644 --- a/src/services/VoiceService.ts +++ b/src/services/VoiceService.ts @@ -1,7 +1,7 @@ import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } from 'discord.js'; import { prisma } from '../database'; import { logger } from '../utils/logger'; -import { redis } from '../cache'; +import { tryAcquireLock } from '../cache'; import { ErrorDefs } from '../errors/ErrorCodes'; import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n'; import { getContextLocale } from '../i18n/localeHelper'; @@ -10,8 +10,7 @@ import { auditLogService } from './AuditLogService'; export class VoiceService { public static async syncChannels(client: Client) { const lockKey = 'voice:sync:lock'; - // NX = only set if not exists, EX = expire in 60s - const acquired = await redis.set(lockKey, '1', 'EX', 60, 'NX'); + const acquired = await tryAcquireLock(lockKey, 60); if (!acquired) { logger.info('VoiceService: Another instance is already syncing channels. Skipping.'); return; @@ -20,7 +19,7 @@ export class VoiceService { try { logger.info('VoiceService: Starting channel synchronization...'); const channels = await prisma.tempVoiceChannel.findMany(); - + for (const temp of channels) { try { const guild = client.guilds.cache.get(temp.guildId) || await client.guilds.fetch(temp.guildId).catch(() => null); @@ -55,9 +54,8 @@ export class VoiceService { } } logger.info('VoiceService: Channel synchronization complete.'); - } finally { - // Free the lock just in case, though EX ensures it doesn't hang forever - await redis.del(lockKey).catch(() => {}); + } catch (error) { + logger.error('VoiceService: Failed during channel synchronization', error); } } public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) { diff --git a/src/services/WebhookService.ts b/src/services/WebhookService.ts index 25dbec7..2f2d3bc 100644 --- a/src/services/WebhookService.ts +++ b/src/services/WebhookService.ts @@ -1,6 +1,6 @@ import { TextChannel, WebhookClient } from 'discord.js'; import { logger } from '../utils/logger'; -import { redis } from '../cache'; +import { cache } from '../cache'; export class WebhookService { private static readonly MAX_WEBHOOKS = 10; @@ -9,7 +9,7 @@ export class WebhookService { public static async getWebhookClient(channel: TextChannel): Promise { try { // 1. Check cache - const cachedData = await redis.get(`webhook:${channel.id}`); + const cachedData = await cache.get(`webhook:${channel.id}`); if (cachedData) { const { id, token } = JSON.parse(cachedData); return new WebhookClient({ id, token }); @@ -40,14 +40,11 @@ export class WebhookService { logger.info(`Created new webhook for channel ${channel.id}`); } - // 3. Save to Redis Cache (expire in 1 day to ensure token freshness) + // 3. Save to cache (expire in 1 day to ensure token freshness) if (kordWebhook.token) { - await redis.set( - `webhook:${channel.id}`, - JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }), - 'EX', - 86400 - ); + await cache.set(`webhook:${channel.id}`, JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }), { + exSeconds: 86400, + }); return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token }); } diff --git a/yarn.lock b/yarn.lock index e9c7535..f235be0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -774,13 +774,6 @@ __metadata: languageName: node linkType: hard -"@ioredis/commands@npm:1.5.1": - version: 1.5.1 - resolution: "@ioredis/commands@npm:1.5.1" - checksum: 10c0/cb8f6d13cff0753e3e7ef001fb895491985d9a623248192538f13bc2fd9bfdfde3c18cf2ba6f20ec8ceaa681b0771070d3a09b82eed044c798bcfef5e3ae54b3 - languageName: node - linkType: hard - "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -2105,13 +2098,6 @@ __metadata: languageName: node linkType: hard -"cluster-key-slot@npm:^1.1.0": - version: 1.1.2 - resolution: "cluster-key-slot@npm:1.1.2" - checksum: 10c0/d7d39ca28a8786e9e801eeb8c770e3c3236a566625d7299a47bb71113fb2298ce1039596acb82590e598c52dbc9b1f088c8f587803e697cb58e1867a95ff94d3 - languageName: node - linkType: hard - "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -2205,13 +2191,6 @@ __metadata: languageName: node linkType: hard -"denque@npm:^2.1.0": - version: 2.1.0 - resolution: "denque@npm:2.1.0" - checksum: 10c0/f9ef81aa0af9c6c614a727cb3bd13c5d7db2af1abf9e6352045b86e85873e629690f6222f4edd49d10e4ccf8f078bbeec0794fafaf61b659c0589d0c511ec363 - languageName: node - linkType: hard - "detect-newline@npm:^3.1.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" @@ -2947,23 +2926,6 @@ __metadata: languageName: node linkType: hard -"ioredis@npm:^5.10.1": - version: 5.10.1 - resolution: "ioredis@npm:5.10.1" - dependencies: - "@ioredis/commands": "npm:1.5.1" - cluster-key-slot: "npm:^1.1.0" - debug: "npm:^4.3.4" - denque: "npm:^2.1.0" - lodash.defaults: "npm:^4.2.0" - lodash.isarguments: "npm:^3.1.0" - redis-errors: "npm:^1.2.0" - redis-parser: "npm:^3.0.0" - standard-as-callback: "npm:^2.1.0" - checksum: 10c0/d0507b52520d3bdd5dacaa33aed9dd3133794d8633b43a6b7fc3199a5e73f92cb77409f6904abe68e3221a95a630d97073b8c1c9e2c0c7613124db67e97c0eb0 - languageName: node - linkType: hard - "ip-address@npm:^10.0.1": version: 10.1.0 resolution: "ip-address@npm:10.1.0" @@ -3617,7 +3579,6 @@ __metadata: discord.js: "npm:^14.25.1" dotenv: "npm:^17.3.1" eslint: "npm:^10.1.0" - ioredis: "npm:^5.10.1" jest: "npm:^30.3.0" prettier: "npm:^3.8.1" prisma: "npm:6.4.1" @@ -3669,20 +3630,6 @@ __metadata: languageName: node linkType: hard -"lodash.defaults@npm:^4.2.0": - version: 4.2.0 - resolution: "lodash.defaults@npm:4.2.0" - checksum: 10c0/d5b77aeb702caa69b17be1358faece33a84497bcca814897383c58b28a2f8dfc381b1d9edbec239f8b425126a3bbe4916223da2a576bb0411c2cefd67df80707 - languageName: node - linkType: hard - -"lodash.isarguments@npm:^3.1.0": - version: 3.1.0 - resolution: "lodash.isarguments@npm:3.1.0" - checksum: 10c0/5e8f95ba10975900a3920fb039a3f89a5a79359a1b5565e4e5b4310ed6ebe64011e31d402e34f577eca983a1fc01ff86c926e3cbe602e1ddfc858fdd353e62d8 - languageName: node - linkType: hard - "lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" @@ -4257,22 +4204,6 @@ __metadata: languageName: node linkType: hard -"redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0": - version: 1.2.0 - resolution: "redis-errors@npm:1.2.0" - checksum: 10c0/5b316736e9f532d91a35bff631335137a4f974927bb2fb42bf8c2f18879173a211787db8ac4c3fde8f75ed6233eb0888e55d52510b5620e30d69d7d719c8b8a7 - languageName: node - linkType: hard - -"redis-parser@npm:^3.0.0": - version: 3.0.0 - resolution: "redis-parser@npm:3.0.0" - dependencies: - redis-errors: "npm:^1.0.0" - checksum: 10c0/ee16ac4c7b2a60b1f42a2cdaee22b005bd4453eb2d0588b8a4939718997ae269da717434da5d570fe0b05030466eeb3f902a58cf2e8e1ca058bf6c9c596f632f - languageName: node - linkType: hard - "require-directory@npm:^2.1.1": version: 2.1.1 resolution: "require-directory@npm:2.1.1" @@ -4435,13 +4366,6 @@ __metadata: languageName: node linkType: hard -"standard-as-callback@npm:^2.1.0": - version: 2.1.0 - resolution: "standard-as-callback@npm:2.1.0" - checksum: 10c0/012677236e3d3fdc5689d29e64ea8a599331c4babe86956bf92fc5e127d53f85411c5536ee0079c52c43beb0026b5ce7aa1d834dd35dd026e82a15d1bcaead1f - languageName: node - linkType: hard - "string-length@npm:^4.0.2": version: 4.0.2 resolution: "string-length@npm:4.0.2"