레디스 제거 버전 작업
This commit is contained in:
parent
019cb314be
commit
e43af4f944
|
|
@ -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.
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
76
yarn.lock
76
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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue