feat: Initialize Discord bot project with core client, event handlers, services, database, caching, and infrastructure.
This commit is contained in:
parent
9513c39194
commit
da47c4b140
|
|
@ -0,0 +1,8 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/.yarn/** linguist-vendored
|
||||
/.yarn/releases/* binary
|
||||
/.yarn/plugins/**/* binary
|
||||
/.pnp.* binary linguist-generated
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
nodeLinker: node-modules
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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:
|
||||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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...`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 || '',
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { KordClient } from './client/KordClient';
|
||||
|
||||
const client = new KordClient();
|
||||
client.start();
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
|
@ -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),
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { InviteService } from '../../src/services/InviteService';
|
||||
|
||||
describe('InviteService Test Suite', () => {
|
||||
it('should be defined', () => {
|
||||
expect(InviteService).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { MimicService } from '../../src/services/MimicService';
|
||||
|
||||
describe('MimicService Test Suite', () => {
|
||||
it('should be defined', () => {
|
||||
expect(MimicService).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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/**/*"]
|
||||
}
|
||||
Loading…
Reference in New Issue