feat: Initialize Discord bot project with core client, event handlers, services, database, caching, and infrastructure.

This commit is contained in:
이정수 2026-03-27 09:40:50 +09:00
parent 9513c39194
commit da47c4b140
37 changed files with 5755 additions and 0 deletions

8
.editorconfig Normal file
View File

@ -0,0 +1,8 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true

15
.env.example Normal file
View File

@ -0,0 +1,15 @@
# Discord Authentication
DISCORD_TOKEN=your_token_here
DISCORD_CLIENT_ID=your_client_id_here
# Database Configuration (PostgreSQL)
# 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
# Voice Master
VOICE_WAITING_ROOM_ID=your_waiting_room_channel_id
VOICE_CATEGORY_ID=your_category_id_where_channels_are_created

16
.eslintrc.js Normal file
View File

@ -0,0 +1,16 @@
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
env: {
node: true,
jest: true,
},
};

4
.gitattributes vendored Normal file
View File

@ -0,0 +1,4 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}

1
.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
nodeLinker: node-modules

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn
RUN corepack enable && yarn install
# Generate Prisma Client
COPY prisma ./prisma/
RUN yarn prisma generate
# Build TypeScript
COPY tsconfig.json ./
COPY src ./src/
RUN yarn tsc
FROM node:20-alpine AS runner
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn
RUN corepack enable && yarn install
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
COPY --from=builder /app/dist ./dist
CMD ["yarn", "node", "dist/index.js"]

1
README.md Normal file
View File

@ -0,0 +1 @@
# Kord

28
docker-compose.yml Normal file
View File

@ -0,0 +1,28 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: kord-postgres
environment:
POSTGRES_USER: kord
POSTGRES_PASSWORD: password
POSTGRES_DB: kord_db
ports:
- "5432:5432"
volumes:
- 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:

10
jest.config.js Normal file
View File

@ -0,0 +1,10 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.ts'],
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
roots: ['<rootDir>/src', '<rootDir>/tests'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "Kord",
"packageManager": "yarn@4.9.1",
"dependencies": {
"@prisma/client": "6.4.1",
"discord.js": "^14.25.1",
"dotenv": "^17.3.1",
"ioredis": "^5.10.1"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^25.5.0",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.57.2",
"eslint": "^10.1.0",
"jest": "^30.3.0",
"prettier": "^3.8.1",
"prisma": "6.4.1",
"ts-jest": "^29.4.6",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "jest"
}
}

26
prisma/schema.prisma Normal file
View File

@ -0,0 +1,26 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model GuildConfig {
guildId String @id
prefix String @default("!")
mimicEnabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model InviteRole {
id String @id @default(uuid())
guildId String
inviteCode String
roleId String
createdAt DateTime @default(now())
@@unique([guildId, inviteCode])
}

23
src/cache/index.ts vendored Normal file
View File

@ -0,0 +1,23 @@
import Redis from 'ioredis';
import { env } from '../config/env';
import { logger } from '../utils/logger';
export const redis = new Redis({
host: env.REDIS_HOST,
port: env.REDIS_PORT,
lazyConnect: true,
});
redis.on('error', (err) => {
logger.error('Redis Error:', err);
});
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);
}
};

47
src/client/KordClient.ts Normal file
View File

@ -0,0 +1,47 @@
import { Client, GatewayIntentBits, Partials, Collection } from 'discord.js';
import { logger } from '../utils/logger';
import { env } from '../config/env';
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();
constructor() {
super({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildInvites,
],
partials: [Partials.Message, Partials.Channel, Partials.GuildMember],
});
}
public async start() {
handleGlobalExceptions();
// Connect to external services
await connectDB();
await connectRedis();
// Load Handlers
await loadCommands(this);
await loadEvents(this);
if (!env.DISCORD_TOKEN) {
logger.warn('DISCORD_TOKEN is missing. Bot cannot start.');
return;
}
// Login
await this.login(env.DISCORD_TOKEN);
logger.info(`Started login sequence...`);
}
}

13
src/config/env.ts Normal file
View File

@ -0,0 +1,13 @@
import { config } from 'dotenv';
config();
export const env = {
NODE_ENV: process.env.NODE_ENV || 'development',
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 || '',
};

16
src/database/index.ts Normal file
View File

@ -0,0 +1,16 @@
import { PrismaClient } from '@prisma/client';
import { logger } from '../utils/logger';
export const prisma = new PrismaClient({
log: ['warn', 'error'],
});
export const connectDB = async () => {
try {
await prisma.$connect();
logger.info('Connected to PostgreSQL successfully.');
} catch (error) {
logger.error('Failed to connect to PostgreSQL:', error);
process.exit(1);
}
};

10
src/events/guildCreate.ts Normal file
View File

@ -0,0 +1,10 @@
import { Events, Guild } from 'discord.js';
import { InviteService } from '../services/InviteService';
export default {
name: Events.GuildCreate,
once: false,
async execute(guild: Guild) {
await InviteService.cacheGuildInvites(guild);
},
};

View File

@ -0,0 +1,10 @@
import { Events, GuildMember } from 'discord.js';
import { InviteService } from '../services/InviteService';
export default {
name: Events.GuildMemberAdd,
once: false,
async execute(member: GuildMember) {
await InviteService.handleMemberAdd(member);
},
};

View File

@ -0,0 +1,10 @@
import { Events, Invite } from 'discord.js';
import { InviteService } from '../services/InviteService';
export default {
name: Events.InviteCreate,
once: false,
async execute(invite: Invite) {
await InviteService.handleInviteCreate(invite);
},
};

View File

@ -0,0 +1,10 @@
import { Events, Invite } from 'discord.js';
import { InviteService } from '../services/InviteService';
export default {
name: Events.InviteDelete,
once: false,
async execute(invite: Invite) {
await InviteService.handleInviteDelete(invite);
},
};

View File

@ -0,0 +1,10 @@
import { Events, Message } from 'discord.js';
import { MimicService } from '../services/MimicService';
export default {
name: Events.MessageCreate,
once: false,
async execute(message: Message) {
await MimicService.handleMessage(message);
},
};

12
src/events/ready.ts Normal file
View File

@ -0,0 +1,12 @@
import { Events, Client } from 'discord.js';
import { logger } from '../utils/logger';
import { InviteService } from '../services/InviteService';
export default {
name: Events.ClientReady,
once: true,
async execute(client: Client) {
logger.info(`Ready! Logged in as ${client.user?.tag}`);
await InviteService.cacheAllInvites(client);
},
};

View File

@ -0,0 +1,10 @@
import { Events, VoiceState } from 'discord.js';
import { VoiceService } from '../services/VoiceService';
export default {
name: Events.VoiceStateUpdate,
once: false,
async execute(oldState: VoiceState, newState: VoiceState) {
await VoiceService.handleVoiceStateUpdate(oldState, newState);
},
};

View File

@ -0,0 +1,22 @@
import { KordClient } from '../client/KordClient';
import { logger } from '../utils/logger';
import fs from 'fs';
import path from 'path';
export const loadCommands = async (client: KordClient) => {
const commandsPath = path.join(__dirname, '../commands');
if (!fs.existsSync(commandsPath)) return;
const commandFiles = fs.readdirSync(commandsPath).filter(f => f.endsWith('.ts') || f.endsWith('.js'));
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath).default;
if (command && 'data' in command && 'execute' in command) {
client.commands.set(command.data.name, command);
logger.debug(`Loaded command: ${command.data.name}`);
} else {
logger.warn(`The command at ${filePath} is missing a required "data" or "execute" property.`);
}
}
};

View File

@ -0,0 +1,27 @@
import { KordClient } from '../client/KordClient';
import { logger } from '../utils/logger';
import fs from 'fs';
import path from 'path';
export const loadEvents = async (client: KordClient) => {
const eventsPath = path.join(__dirname, '../events');
if (!fs.existsSync(eventsPath)) return;
const eventFiles = fs.readdirSync(eventsPath).filter(f => f.endsWith('.ts') || f.endsWith('.js'));
for (const file of eventFiles) {
const filePath = path.join(eventsPath, file);
const event = require(filePath).default;
if (!event || !event.name || !event.execute) {
logger.warn(`Invalid event structure in ${file}`);
continue;
}
if (event.once) {
client.once(event.name, (...args) => event.execute(...args, client));
} else {
client.on(event.name, (...args) => event.execute(...args, client));
}
logger.debug(`Loaded event: ${event.name}`);
}
};

4
src/index.ts Normal file
View File

@ -0,0 +1,4 @@
import { KordClient } from './client/KordClient';
const client = new KordClient();
client.start();

View File

@ -0,0 +1,88 @@
import { Client, Guild, Invite, GuildMember } from 'discord.js';
import { redis } from '../cache';
import { prisma } from '../database';
import { logger } from '../utils/logger';
export class InviteService {
public static async cacheAllInvites(client: Client) {
for (const [, guild] of client.guilds.cache) {
await this.cacheGuildInvites(guild);
}
logger.info('InviteMaster: Finished caching all invites.');
}
public static async cacheGuildInvites(guild: Guild) {
try {
const invites = await guild.invites.fetch();
const inviteData = invites.map(inv => ({
code: inv.code,
uses: inv.uses || 0
}));
await redis.set(`invites:${guild.id}`, JSON.stringify(inviteData));
} catch (error) {
logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error);
}
}
public static async handleInviteCreate(invite: Invite) {
if (!invite.guild) return;
logger.debug(`InviteMaster: New invite created: ${invite.code}`);
await this.cacheGuildInvites(invite.guild as Guild);
}
public static async handleInviteDelete(invite: Invite) {
if (!invite.guild) return;
logger.debug(`InviteMaster: Invite deleted: ${invite.code}`);
await this.cacheGuildInvites(invite.guild as Guild);
}
public static async handleMemberAdd(member: GuildMember) {
const guild = member.guild;
try {
// Fetch current active invites
const newInvites = await guild.invites.fetch();
const cachedData = await redis.get(`invites:${guild.id}`);
let usedInvite: Invite | undefined;
if (cachedData) {
const cachedInvites: { code: string, uses: number }[] = JSON.parse(cachedData);
// Find the invite where 'uses' has increased
usedInvite = newInvites.find(inv => {
const cached = cachedInvites.find(c => c.code === inv.code);
return cached ? (inv.uses || 0) > cached.uses : false;
});
}
// Update the cache immediately to account for this new join
await this.cacheGuildInvites(guild);
if (usedInvite) {
logger.info(`InviteMaster: ${member.user.tag} joined using invite ${usedInvite.code}`);
// Check DB for mapped role
const inviteRole = await prisma.inviteRole.findFirst({
where: {
guildId: guild.id,
inviteCode: usedInvite.code
}
});
if (inviteRole) {
const role = guild.roles.cache.get(inviteRole.roleId);
if (role) {
await member.roles.add(role);
logger.info(`InviteMaster: Assigned role ${role.name} to ${member.user.tag}`);
} else {
logger.warn(`InviteMaster: Role ${inviteRole.roleId} mapped to invite ${usedInvite.code} not found.`);
}
}
} else {
logger.info(`InviteMaster: ${member.user.tag} joined but invite could not be determined (ex: Vanity URL).`);
}
} catch (error) {
logger.error(`InviteMaster: Failed to handle member add tracking:`, error);
}
}
}

View File

@ -0,0 +1,64 @@
import { Message, TextChannel, PermissionFlagsBits } from 'discord.js';
import { WebhookService } from './WebhookService';
import { logger } from '../utils/logger';
export class MimicService {
public static async handleMessage(message: Message) {
if (message.author.bot) return;
if (!(message.channel instanceof TextChannel)) return;
let content = message.content;
let modified = false;
// Feature 1: Big Emoji
// If message is exactly one custom discord emoji, we enlarge it.
const customEmojiRegex = /^<a?:.+:(\d+)>$/i;
const match = content.match(customEmojiRegex);
if (match) {
const emojiId = match[1];
const isAnimated = content.startsWith('<a:');
const ext = isAnimated ? 'gif' : 'png';
const emojiUrl = `https://cdn.discordapp.com/emojis/${emojiId}.${ext}?size=256`;
// Replace the emoji string with its raw image URL
content = emojiUrl;
modified = true;
}
// Feature 2: Prank / Word Mimic
// Example logic replacing a keyword to alter user message
if (content.includes('kord')) {
content = content.replace(/kord/gi, 'Kord(최고존엄)');
modified = true;
}
if (modified) {
try {
// Ensure we have permissions to manage webhooks and messages
const me = message.guild?.members.me;
if (!me?.permissionsIn(message.channel).has(PermissionFlagsBits.ManageWebhooks)) {
logger.warn(`Missing ManageWebhooks in ${message.channel.id}`);
return; // Can't send mimic
}
const webhookClient = await WebhookService.getWebhookClient(message.channel);
if (webhookClient) {
// Send modified message copying the user's name and avatar
await webhookClient.send({
content,
username: message.member?.displayName || message.author.username,
avatarURL: message.author.displayAvatarURL(),
});
// Delete the original message silently
if (message.deletable) {
await message.delete();
}
}
} catch (error) {
logger.error(`MimicService Error:`, error);
}
}
}
}

View File

@ -0,0 +1,91 @@
import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel } from 'discord.js';
import { env } from '../config/env';
import { logger } from '../utils/logger';
// Set to track IDs of dynamic channels
const dynamicChannels = new Set<string>();
export class VoiceService {
public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
const member = newState.member;
if (!member) return;
// Joined a voice channel
if (!oldState.channelId && newState.channelId) {
await this.handleJoin(newState);
}
// Switched voice channels
else if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
await this.handleLeave(oldState);
await this.handleJoin(newState);
}
// Left a voice channel
else if (oldState.channelId && !newState.channelId) {
await this.handleLeave(oldState);
}
}
private static async handleJoin(state: VoiceState) {
if (state.channelId === env.VOICE_WAITING_ROOM_ID) {
try {
const guild = state.guild;
const member = state.member!;
// Ensure bot has permission before creating channel
const botMember = guild.members.me;
if (!botMember?.permissions.has(PermissionFlagsBits.ManageChannels)) {
logger.warn(`Bot lacks ManageChannels permission in guild ${guild.id}`);
return;
}
const newChannel = await guild.channels.create({
name: `${member.user.username}'s Room`,
type: ChannelType.GuildVoice,
parent: env.VOICE_CATEGORY_ID || state.channel?.parentId || undefined,
permissionOverwrites: [
{
id: guild.roles.everyone.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect],
},
{
id: member.id,
allow: [
PermissionFlagsBits.ManageChannels,
PermissionFlagsBits.ManageRoles,
PermissionFlagsBits.MuteMembers,
PermissionFlagsBits.DeafenMembers,
PermissionFlagsBits.MoveMembers,
],
},
],
});
dynamicChannels.add(newChannel.id);
// Move user smoothly to their new room
await member.voice.setChannel(newChannel);
logger.info(`VoiceMaster: Created channel ${newChannel.name} for ${member.user.tag}`);
} catch (error) {
logger.error(`VoiceMaster Join Error:`, error);
}
}
}
private static async handleLeave(state: VoiceState) {
const channelId = state.channelId;
if (!channelId) return;
if (dynamicChannels.has(channelId)) {
const channel = state.channel as VoiceChannel;
if (channel && channel.members.size === 0) {
try {
await channel.delete();
dynamicChannels.delete(channelId);
logger.info(`VoiceMaster: Deleted empty dynamic channel: ${channel.name}`);
} catch (error) {
logger.error(`VoiceMaster Leave Error:`, error);
}
}
}
}
}

View File

@ -0,0 +1,60 @@
import { TextChannel, WebhookClient } from 'discord.js';
import { logger } from '../utils/logger';
import { redis } from '../cache';
export class WebhookService {
private static readonly MAX_WEBHOOKS = 10;
private static readonly WEBHOOK_NAME = 'Kord Mimic Webhook';
public static async getWebhookClient(channel: TextChannel): Promise<WebhookClient | null> {
try {
// 1. Check cache
const cachedData = await redis.get(`webhook:${channel.id}`);
if (cachedData) {
const { id, token } = JSON.parse(cachedData);
return new WebhookClient({ id, token });
}
// 2. Fetch from Discord API
const webhooks = await channel.fetchWebhooks();
let kordWebhook = webhooks.find(wh => wh.name === this.WEBHOOK_NAME && wh.token !== null);
if (!kordWebhook) {
if (webhooks.size >= this.MAX_WEBHOOKS) {
// If we hit limits, delete the oldest webhook
const oldestWebhook = webhooks.last();
if (oldestWebhook) {
await oldestWebhook.delete('Hit max webhook limit for Kord');
logger.warn(`Deleted oldest webhook in channel ${channel.id}`);
} else {
logger.error(`Webhook limits reached in ${channel.id} but no webhook could be deleted.`);
return null;
}
}
kordWebhook = await channel.createWebhook({
name: this.WEBHOOK_NAME,
avatar: channel.client.user?.displayAvatarURL(),
reason: 'Webhook needed for Kord Mimic & Prank feature',
});
logger.info(`Created new webhook for channel ${channel.id}`);
}
// 3. Save to Redis 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
);
return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token });
}
return null;
} catch (error) {
logger.error(`WebhookService Error on channel ${channel.id}:`, error);
return null;
}
}
}

11
src/utils/errorHandler.ts Normal file
View File

@ -0,0 +1,11 @@
import { logger } from './logger';
export const handleGlobalExceptions = () => {
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', error);
});
};

6
src/utils/logger.ts Normal file
View File

@ -0,0 +1,6 @@
export const logger = {
info: (...args: any[]) => console.log('\x1b[36m[INFO]\x1b[0m', ...args),
warn: (...args: any[]) => console.log('\x1b[33m[WARN]\x1b[0m', ...args),
error: (...args: any[]) => console.error('\x1b[31m[ERROR]\x1b[0m', ...args),
debug: (...args: any[]) => console.debug('\x1b[90m[DEBUG]\x1b[0m', ...args),
};

View File

@ -0,0 +1,7 @@
import { InviteService } from '../../src/services/InviteService';
describe('InviteService Test Suite', () => {
it('should be defined', () => {
expect(InviteService).toBeDefined();
});
});

View File

@ -0,0 +1,7 @@
import { MimicService } from '../../src/services/MimicService';
describe('MimicService Test Suite', () => {
it('should be defined', () => {
expect(MimicService).toBeDefined();
});
});

View File

@ -0,0 +1,11 @@
import { VoiceService } from '../../src/services/VoiceService';
import { VoiceState } from 'discord.js';
describe('VoiceService Test Suite', () => {
it('should ignore when member is not present in the voice state', async () => {
// Mocking discord.js objects is complex, so we ensure the service handles null safety
const mockState = { channelId: null } as VoiceState;
await VoiceService.handleVoiceStateUpdate(mockState, mockState);
expect(true).toBe(true);
});
});

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}

4998
yarn.lock Normal file

File diff suppressed because it is too large Load Diff