레디스 제거 버전 작업

This commit is contained in:
pandoli365 2026-03-30 20:48:03 +09:00
parent 019cb314be
commit e43af4f944
13 changed files with 80 additions and 141 deletions

View File

@ -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
## NOTE
## This project does not use Redis.

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

View File

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

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

View File

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

65
src/cache/index.ts vendored
View File

@ -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<string, CacheEntry>();
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<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,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<string, any> = new Collection();
@ -28,7 +27,6 @@ export class KordClient extends Client {
// Connect to external services
await connectDB();
await connectRedis();
// Load Handlers
await loadCommands(this);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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