레디스 제거 버전 작업

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 # User/pass from docker-compose.yml
DATABASE_URL="postgresql://kord:password@localhost:5432/kord_db?schema=public" DATABASE_URL="postgresql://kord:password@localhost:5432/kord_db?schema=public"
# Redis Configuration ## NOTE
REDIS_HOST="localhost" ## This project does not use Redis.
REDIS_PORT=6379

View File

@ -2,7 +2,7 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./ COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn COPY .yarn ./.yarn
RUN corepack enable && yarn install RUN corepack enable && yarn install --immutable
# Generate Prisma Client # Generate Prisma Client
COPY prisma ./prisma/ COPY prisma ./prisma/
@ -17,9 +17,8 @@ FROM node:20-alpine AS runner
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./ COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn COPY .yarn ./.yarn
RUN corepack enable && yarn install RUN corepack enable && yarn install --immutable --production
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
CMD ["yarn", "node", "dist/index.js"] CMD ["node", "dist/index.js"]

View File

@ -4,14 +4,13 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
## 1. 개요 (Overview) ## 1. 개요 (Overview)
**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)와 Redis를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다. **Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다.
## 2. 요구사항 (Requirements) ## 2. 요구사항 (Requirements)
- **Runtime**: Node.js v20 이상 - **Runtime**: Node.js v20 이상
- **Package Manager**: Yarn v4 (Berry) - **Package Manager**: Yarn v4 (Berry)
- **Database**: PostgreSQL (Prisma 사용) - **Database**: PostgreSQL (Prisma 사용)
- **Cache**: Redis (다중 인스턴스 동기화 및 캐싱)
- **Discord**: Bot Token 및 Client ID (Slash Command 등록용) - **Discord**: Bot Token 및 Client ID (Slash Command 등록용)
## 3. 테스트 방법 (Test Methods) ## 3. 테스트 방법 (Test Methods)
@ -59,7 +58,7 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
1. **빌드**: `yarn build` 1. **빌드**: `yarn build`
2. **실행**: `yarn start` 2. **실행**: `yarn start`
3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB, Redis)을 실행할 수 있습니다. 3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB)을 실행할 수 있습니다.
## 5. 기능 목록 (Feature List) ## 5. 기능 목록 (Feature List)

View File

@ -1,6 +1,15 @@
version: '3.8' version: '3.8'
services: services:
kord:
build: .
container_name: kord-bot
env_file:
- .env
depends_on:
- postgres
restart: unless-stopped
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: kord-postgres container_name: kord-postgres
@ -14,15 +23,5 @@ services:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
restart: unless-stopped restart: unless-stopped
redis:
image: redis:7-alpine
container_name: kord-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
volumes: volumes:
postgres_data: postgres_data:
redis_data:

View File

@ -4,8 +4,7 @@
"dependencies": { "dependencies": {
"@prisma/client": "6.4.1", "@prisma/client": "6.4.1",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.3.1", "dotenv": "^17.3.1"
"ioredis": "^5.10.1"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",

65
src/cache/index.ts vendored
View File

@ -1,23 +1,52 @@
import Redis from 'ioredis'; type CacheEntry = { value: string; expiresAtMs?: number };
import { env } from '../config/env';
import { logger } from '../utils/logger';
export const redis = new Redis({ const nowMs = () => Date.now();
host: env.REDIS_HOST,
port: env.REDIS_PORT,
lazyConnect: true,
});
redis.on('error', (err) => { class LocalCache {
logger.error('Redis Error:', err); private store = new Map<string, CacheEntry>();
});
export const connectRedis = async () => { private isExpired(entry: CacheEntry | undefined) {
try { return entry?.expiresAtMs !== undefined && entry.expiresAtMs <= nowMs();
await redis.connect();
logger.info('Connected to Redis successfully.');
} catch (error) {
logger.error('Failed to connect to Redis:', error);
process.exit(1);
} }
private getEntry(key: string): CacheEntry | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (this.isExpired(entry)) {
this.store.delete(key);
return undefined;
}
return entry;
}
async get(key: string): Promise<string | null> {
const entry = this.getEntry(key);
return entry ? entry.value : null;
}
async set(key: string, value: string, opts?: { exSeconds?: number; nx?: boolean }): Promise<boolean> {
const existing = this.getEntry(key);
if (opts?.nx && existing) return false;
const expiresAtMs = opts?.exSeconds !== undefined ? nowMs() + opts.exSeconds * 1000 : undefined;
this.store.set(key, { value, expiresAtMs });
return true;
}
async del(key: string): Promise<boolean> {
const existed = this.getEntry(key) !== undefined;
this.store.delete(key);
return existed;
}
}
export const cache = new LocalCache();
/**
* Best-effort local process lock.
* - Returns true only once per key during ttl window.
* - Single process only (no cross-instance coordination).
*/
export const tryAcquireLock = async (key: string, ttlSeconds: number): Promise<boolean> => {
return cache.set(`lock:${key}`, '1', { exSeconds: ttlSeconds, nx: true });
}; };

View File

@ -5,7 +5,6 @@ import { loadCommands } from '../handlers/CommandLoader';
import { loadEvents } from '../handlers/EventLoader'; import { loadEvents } from '../handlers/EventLoader';
import { handleGlobalExceptions } from '../utils/errorHandler'; import { handleGlobalExceptions } from '../utils/errorHandler';
import { connectDB } from '../database'; import { connectDB } from '../database';
import { connectRedis } from '../cache';
export class KordClient extends Client { export class KordClient extends Client {
public commands: Collection<string, any> = new Collection(); public commands: Collection<string, any> = new Collection();
@ -28,7 +27,6 @@ export class KordClient extends Client {
// Connect to external services // Connect to external services
await connectDB(); await connectDB();
await connectRedis();
// Load Handlers // Load Handlers
await loadCommands(this); await loadCommands(this);

View File

@ -11,8 +11,6 @@ export const env = {
DISCORD_TOKEN: process.env.DISCORD_TOKEN || '', DISCORD_TOKEN: process.env.DISCORD_TOKEN || '',
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID || '', DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID || '',
DATABASE_URL: process.env.DATABASE_URL || '', DATABASE_URL: process.env.DATABASE_URL || '',
REDIS_HOST: process.env.REDIS_HOST || 'localhost',
REDIS_PORT: parseInt(process.env.REDIS_PORT || '6379', 10),
VOICE_WAITING_ROOM_ID: process.env.VOICE_WAITING_ROOM_ID || '', VOICE_WAITING_ROOM_ID: process.env.VOICE_WAITING_ROOM_ID || '',
VOICE_CATEGORY_ID: process.env.VOICE_CATEGORY_ID || '', VOICE_CATEGORY_ID: process.env.VOICE_CATEGORY_ID || '',
INSTANCE_ID: generateInstanceId(), INSTANCE_ID: generateInstanceId(),

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 { redis } from '../cache'; import { tryAcquireLock } 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 redis.set(lockKey, '1', 'EX', 300, 'NX'); const acquired = await tryAcquireLock(lockKey, 300);
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,5 +1,5 @@
import { Client, Guild, Invite, GuildMember } from 'discord.js'; import { Client, Guild, Invite, GuildMember } from 'discord.js';
import { redis } from '../cache'; import { cache } from '../cache';
import { prisma } from '../database'; import { prisma } from '../database';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
@ -18,7 +18,7 @@ export class InviteService {
code: inv.code, code: inv.code,
uses: inv.uses || 0 uses: inv.uses || 0
})); }));
await redis.set(`invites:${guild.id}`, JSON.stringify(inviteData)); await cache.set(`invites:${guild.id}`, JSON.stringify(inviteData));
} catch (error) { } catch (error) {
logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error); logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error);
} }
@ -41,7 +41,7 @@ export class InviteService {
try { try {
// Fetch current active invites // Fetch current active invites
const newInvites = await guild.invites.fetch(); const newInvites = await guild.invites.fetch();
const cachedData = await redis.get(`invites:${guild.id}`); const cachedData = await cache.get(`invites:${guild.id}`);
let usedInvite: Invite | undefined; let usedInvite: Invite | undefined;

View File

@ -1,7 +1,7 @@
import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } from 'discord.js'; import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } from 'discord.js';
import { prisma } from '../database'; import { prisma } from '../database';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { redis } from '../cache'; import { tryAcquireLock } from '../cache';
import { ErrorDefs } from '../errors/ErrorCodes'; import { ErrorDefs } from '../errors/ErrorCodes';
import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n'; import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n';
import { getContextLocale } from '../i18n/localeHelper'; import { getContextLocale } from '../i18n/localeHelper';
@ -10,8 +10,7 @@ import { auditLogService } from './AuditLogService';
export class VoiceService { export class VoiceService {
public static async syncChannels(client: Client) { public static async syncChannels(client: Client) {
const lockKey = 'voice:sync:lock'; const lockKey = 'voice:sync:lock';
// NX = only set if not exists, EX = expire in 60s const acquired = await tryAcquireLock(lockKey, 60);
const acquired = await redis.set(lockKey, '1', 'EX', 60, 'NX');
if (!acquired) { if (!acquired) {
logger.info('VoiceService: Another instance is already syncing channels. Skipping.'); logger.info('VoiceService: Another instance is already syncing channels. Skipping.');
return; return;
@ -20,7 +19,7 @@ export class VoiceService {
try { try {
logger.info('VoiceService: Starting channel synchronization...'); logger.info('VoiceService: Starting channel synchronization...');
const channels = await prisma.tempVoiceChannel.findMany(); const channels = await prisma.tempVoiceChannel.findMany();
for (const temp of channels) { for (const temp of channels) {
try { try {
const guild = client.guilds.cache.get(temp.guildId) || await client.guilds.fetch(temp.guildId).catch(() => null); 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.'); logger.info('VoiceService: Channel synchronization complete.');
} finally { } catch (error) {
// Free the lock just in case, though EX ensures it doesn't hang forever logger.error('VoiceService: Failed during channel synchronization', error);
await redis.del(lockKey).catch(() => {});
} }
} }
public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) { public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {

View File

@ -1,6 +1,6 @@
import { TextChannel, WebhookClient } from 'discord.js'; import { TextChannel, WebhookClient } from 'discord.js';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { redis } from '../cache'; import { cache } from '../cache';
export class WebhookService { export class WebhookService {
private static readonly MAX_WEBHOOKS = 10; private static readonly MAX_WEBHOOKS = 10;
@ -9,7 +9,7 @@ export class WebhookService {
public static async getWebhookClient(channel: TextChannel): Promise<WebhookClient | null> { public static async getWebhookClient(channel: TextChannel): Promise<WebhookClient | null> {
try { try {
// 1. Check cache // 1. Check cache
const cachedData = await redis.get(`webhook:${channel.id}`); const cachedData = await cache.get(`webhook:${channel.id}`);
if (cachedData) { if (cachedData) {
const { id, token } = JSON.parse(cachedData); const { id, token } = JSON.parse(cachedData);
return new WebhookClient({ id, token }); return new WebhookClient({ id, token });
@ -40,14 +40,11 @@ export class WebhookService {
logger.info(`Created new webhook for channel ${channel.id}`); logger.info(`Created new webhook for channel ${channel.id}`);
} }
// 3. Save to Redis Cache (expire in 1 day to ensure token freshness) // 3. Save to cache (expire in 1 day to ensure token freshness)
if (kordWebhook.token) { if (kordWebhook.token) {
await redis.set( await cache.set(`webhook:${channel.id}`, JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }), {
`webhook:${channel.id}`, exSeconds: 86400,
JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }), });
'EX',
86400
);
return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token }); return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token });
} }

View File

@ -774,13 +774,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@isaacs/cliui@npm:^8.0.2":
version: 8.0.2 version: 8.0.2
resolution: "@isaacs/cliui@npm:8.0.2" resolution: "@isaacs/cliui@npm:8.0.2"
@ -2105,13 +2098,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "co@npm:^4.6.0":
version: 4.6.0 version: 4.6.0
resolution: "co@npm:4.6.0" resolution: "co@npm:4.6.0"
@ -2205,13 +2191,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "detect-newline@npm:^3.1.0":
version: 3.1.0 version: 3.1.0
resolution: "detect-newline@npm:3.1.0" resolution: "detect-newline@npm:3.1.0"
@ -2947,23 +2926,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "ip-address@npm:^10.0.1":
version: 10.1.0 version: 10.1.0
resolution: "ip-address@npm:10.1.0" resolution: "ip-address@npm:10.1.0"
@ -3617,7 +3579,6 @@ __metadata:
discord.js: "npm:^14.25.1" discord.js: "npm:^14.25.1"
dotenv: "npm:^17.3.1" dotenv: "npm:^17.3.1"
eslint: "npm:^10.1.0" eslint: "npm:^10.1.0"
ioredis: "npm:^5.10.1"
jest: "npm:^30.3.0" jest: "npm:^30.3.0"
prettier: "npm:^3.8.1" prettier: "npm:^3.8.1"
prisma: "npm:6.4.1" prisma: "npm:6.4.1"
@ -3669,20 +3630,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "lodash.memoize@npm:^4.1.2":
version: 4.1.2 version: 4.1.2
resolution: "lodash.memoize@npm:4.1.2" resolution: "lodash.memoize@npm:4.1.2"
@ -4257,22 +4204,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "require-directory@npm:^2.1.1":
version: 2.1.1 version: 2.1.1
resolution: "require-directory@npm:2.1.1" resolution: "require-directory@npm:2.1.1"
@ -4435,13 +4366,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "string-length@npm:^4.0.2":
version: 4.0.2 version: 4.0.2
resolution: "string-length@npm:4.0.2" resolution: "string-length@npm:4.0.2"