feat: Implement `/config` command for managing bot features, refactor Big Emoji into a dedicated service, and update guild configuration schema with new defaults.

This commit is contained in:
이정수 2026-03-27 17:16:15 +09:00
parent b81bc6b146
commit 4246eb90a5
13 changed files with 215 additions and 42 deletions

24
Docs/Design_Principles.md Normal file
View File

@ -0,0 +1,24 @@
# Kord 디자인 원칙 (Design Principles)
Kord 봇의 기능 설계 및 사용자 경험(UX) 고도화를 위한 핵심 원칙입니다.
## 1. 부가 기능의 기본 비활성화 (Opt-in by Default)
사용자의 서버 운영에 필수가 아닌 부가 기능(Fun features, 유틸리티 성격의 부가 기능 등)은 초기 도입 시 **기본적으로 비활성화(Disabled)** 상태여야 합니다.
- **이유**: 서버 관리자가 의도하지 않은 봇의 반응(메시지 치환, 자동 이모지 확대 등)으로 인한 혼선을 방지하기 위함입니다.
- **예시**: 미믹(Mimic), 이모지 확대(Big Emoji) 등.
## 2. 설정 도우미(Setup Wizard)의 간결성 유지
`/setup` 명령어를 통해 제공되는 설정 도우미는 서버 운영의 **핵심 필수 설정**에만 집중합니다.
- **포함 대상**: 언어 설정, 보안/감사 로그 채널, 필수 권한 점검, 핵심 서비스(임시 음성 채널 등) 진입점 설정.
- **제외 대상**: 미믹 활성화 여부, 이모지 확대 여부 등 부가적인 환경 설정.
- **설정 방법**: 설정 도우미에서 제외된 기능은 별도의 관리 명령어(예: `/config`)를 통해 개별적으로 활성화할 수 있도록 제공합니다.
## 3. 용어의 일관성 (Terminology)
기능의 명칭은 기술적 정의와 사용자 친화도 사이의 균형을 맞추며, 한 번 정해진 고유 명사는 일관되게 사용합니다.
- **예시**: 사용자의 메시지를 흉내 내는 기능은 '흉내'라는 일반 명사 대신 **'미믹(Mimic)'**이라는 고유 명사를 프로젝트 전반(코드, 번역, 문서)에서 사용합니다.

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "GuildConfig" ADD COLUMN "bigEmojiEnabled" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "mimicEnabled" SET DEFAULT false;

View File

@ -10,7 +10,8 @@ datasource db {
model GuildConfig { model GuildConfig {
guildId String @id guildId String @id
prefix String @default("!") prefix String @default("!")
mimicEnabled Boolean @default(true) mimicEnabled Boolean @default(false)
bigEmojiEnabled Boolean @default(false)
locale String? locale String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -16,8 +16,7 @@ export class KordClient extends Client {
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessages,
// GatewayIntentBits.MessageContent, // Privileged -> Disabled for testing GatewayIntentBits.MessageContent,
// GatewayIntentBits.GuildMembers, // Privileged -> Disabled for testing
GatewayIntentBits.GuildInvites, GatewayIntentBits.GuildInvites,
], ],
partials: [Partials.Message, Partials.Channel, Partials.GuildMember], partials: [Partials.Message, Partials.Channel, Partials.GuildMember],

63
src/commands/config.ts Normal file
View File

@ -0,0 +1,63 @@
import { SlashCommandBuilder, ChatInputCommandInteraction, PermissionFlagsBits, EmbedBuilder } from 'discord.js';
import { prisma } from '../database';
import { t, resolveLocale } from '../i18n';
export default {
data: new SlashCommandBuilder()
.setName('config')
.setDescription('Configure bot features')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addBooleanOption(opt => opt.setName('mimic').setDescription('Enable or disable Mimic feature'))
.addBooleanOption(opt => opt.setName('emoji').setDescription('Enable or disable Big Emoji feature')),
async execute(interaction: ChatInputCommandInteraction) {
if (!interaction.guildId) return;
const mimic = interaction.options.getBoolean('mimic');
const emoji = interaction.options.getBoolean('emoji');
// Resolve proper supported locale
const locale = resolveLocale({
discordLocale: interaction.locale,
guildLocale: interaction.guildLocale?.toString(),
});
if (mimic === null && emoji === null) {
return interaction.reply({
content: t(locale, 'commands.config.noOptions'),
ephemeral: true
});
}
const updateData: any = {};
if (mimic !== null) updateData.mimicEnabled = mimic;
if (emoji !== null) updateData.bigEmojiEnabled = emoji;
await prisma.guildConfig.upsert({
where: { guildId: interaction.guildId },
update: updateData,
create: {
guildId: interaction.guildId,
mimicEnabled: mimic ?? false,
bigEmojiEnabled: emoji ?? false,
},
});
const results: string[] = [];
if (mimic !== null) {
const state = mimic ? t(locale, 'commands.config.mimic.enabled') : t(locale, 'commands.config.mimic.disabled');
results.push(`${t(locale, 'commands.config.mimic.label')}: **${state}**`);
}
if (emoji !== null) {
const state = emoji ? t(locale, 'commands.config.emoji.enabled') : t(locale, 'commands.config.emoji.disabled');
results.push(`${t(locale, 'commands.config.emoji.label')}: **${state}**`);
}
const embed = new EmbedBuilder()
.setColor(0x00AE86)
.setTitle(t(locale, 'commands.config.title'))
.setDescription(results.join('\n'));
await interaction.reply({ embeds: [embed], ephemeral: true });
},
};

View File

@ -1,10 +1,24 @@
import { Events, Message } from 'discord.js'; import { Events, Message } from 'discord.js';
import { MimicService } from '../services/MimicService'; import { MimicService } from '../services/MimicService';
import { BigEmojiService } from '../services/BigEmojiService';
import { prisma } from '../database';
export default { export default {
name: Events.MessageCreate, name: Events.MessageCreate,
once: false, once: false,
async execute(message: Message) { async execute(message: Message) {
if (!message.guildId || message.author.bot) return;
const config = await prisma.guildConfig.findUnique({
where: { guildId: message.guildId }
});
if (config?.bigEmojiEnabled) {
await BigEmojiService.handleMessage(message);
}
if (config?.mimicEnabled) {
await MimicService.handleMessage(message); await MimicService.handleMessage(message);
}
}, },
}; };

View File

@ -11,12 +11,18 @@ export const loadCommands = async (client: KordClient) => {
for (const file of commandFiles) { for (const file of commandFiles) {
const filePath = path.join(commandsPath, file); const filePath = path.join(commandsPath, file);
const command = require(filePath).default; try {
const module = require(filePath);
const command = module.default || module;
if (command && 'data' in command && 'execute' in command) { if (command && 'data' in command && 'execute' in command) {
client.commands.set(command.data.name, command); client.commands.set(command.data.name, command);
logger.debug(`Loaded command: ${command.data.name}`); logger.debug(`Loaded command: ${command.data.name}`);
} else { } else {
logger.warn(`The command at ${filePath} is missing a required "data" or "execute" property.`); logger.warn(`The command at ${filePath} is missing a required "data" or "execute" property.`);
} }
} catch (err) {
logger.error(`Failed to load command at ${filePath}:`, err);
}
} }
}; };

View File

@ -218,7 +218,6 @@ export const en: TranslationSchema = {
VOICE: 'Voice', VOICE: 'Voice',
PERMISSION: 'Permission', PERMISSION: 'Permission',
INVITE: 'Invite', INVITE: 'Invite',
MIMIC: 'Mimic',
}, },
}, },
}, },
@ -245,4 +244,18 @@ export const en: TranslationSchema = {
managing: 'Managing Temp Voice Channels', managing: 'Managing Temp Voice Channels',
version: 'Kord v1.0.0', version: 'Kord v1.0.0',
}, },
config: {
title: 'Feature Configuration',
noOptions: 'Please provide at least one option to configure.',
mimic: {
label: 'Mimic',
enabled: 'enabled',
disabled: 'disabled',
},
emoji: {
label: 'Big Emoji',
enabled: 'enabled',
disabled: 'disabled',
},
},
}; };

View File

@ -192,8 +192,8 @@ export const ko: TranslationSchema = {
nextBtn: '다음 단계' nextBtn: '다음 단계'
}, },
step4: { step4: {
title: '3-1감사 로그 카테고리', title: '감사 로그 카테고리 설정',
desc: '수신할 로그 카테고리를 선택하세요. 버튼 색상이 **초록색**이면 수신, **빨간색**이면 차단 상태입니다.', desc: '로그를 수신할 카테고리를 선택해주세요.',
nextBtn: '다음 단계', nextBtn: '다음 단계',
}, },
step5: { step5: {
@ -218,7 +218,6 @@ export const ko: TranslationSchema = {
VOICE: '음성', VOICE: '음성',
PERMISSION: '권한', PERMISSION: '권한',
INVITE: '초대', INVITE: '초대',
MIMIC: '흉내',
}, },
}, },
}, },
@ -245,4 +244,18 @@ export const ko: TranslationSchema = {
managing: '임시 음성 채널 관리 중', managing: '임시 음성 채널 관리 중',
version: 'Kord v1.0.0', version: 'Kord v1.0.0',
}, },
config: {
title: '기능 설정 변경 결과',
noOptions: '변경할 옵션을 하나 이상 선택해주세요.',
mimic: {
label: '미믹(Mimic)',
enabled: '활성화',
disabled: '비활성화',
},
emoji: {
label: '이모지 확대(Big Emoji)',
enabled: '활성화',
disabled: '비활성화',
},
},
}; };

View File

@ -135,10 +135,23 @@ export interface TranslationSchema {
VOICE: string; VOICE: string;
PERMISSION: string; PERMISSION: string;
INVITE: string; INVITE: string;
MIMIC: string;
}; };
}; };
}; };
config: {
title: string;
noOptions: string;
mimic: {
label: string;
enabled: string;
disabled: string;
};
emoji: {
label: string;
enabled: string;
disabled: string;
};
};
// ── Modals ── // ── Modals ──
modals: { modals: {

View File

@ -0,0 +1,45 @@
import { Message, TextChannel, PermissionFlagsBits } from 'discord.js';
import { WebhookService } from './WebhookService';
import { logger } from '../utils/logger';
export class BigEmojiService {
public static async handleMessage(message: Message) {
if (message.author.bot) return;
if (!(message.channel instanceof TextChannel)) return;
const content = message.content;
// Check if message is exactly one custom discord emoji
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`;
try {
const me = message.guild?.members.me;
if (!me?.permissionsIn(message.channel).has(PermissionFlagsBits.ManageWebhooks)) {
return;
}
const webhookClient = await WebhookService.getWebhookClient(message.channel);
if (webhookClient) {
await webhookClient.send({
content: emojiUrl,
username: message.member?.displayName || message.author.username,
avatarURL: message.author.displayAvatarURL(),
});
if (message.deletable) {
await message.delete();
}
}
} catch (error) {
logger.error(`BigEmojiService Error:`, error);
}
}
}
}

View File

@ -10,48 +10,27 @@ export class MimicService {
let content = message.content; let content = message.content;
let modified = false; let modified = false;
// Feature 1: Big Emoji // Feature: Word Mimic
// If message is exactly one custom discord emoji, we enlarge it. if (content.toLowerCase().includes('kord')) {
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(최고존엄)'); content = content.replace(/kord/gi, 'Kord(최고존엄)');
modified = true; modified = true;
} }
if (modified) { if (modified) {
try { try {
// Ensure we have permissions to manage webhooks and messages
const me = message.guild?.members.me; const me = message.guild?.members.me;
if (!me?.permissionsIn(message.channel).has(PermissionFlagsBits.ManageWebhooks)) { if (!me?.permissionsIn(message.channel).has(PermissionFlagsBits.ManageWebhooks)) {
logger.warn(`Missing ManageWebhooks in ${message.channel.id}`); return;
return; // Can't send mimic
} }
const webhookClient = await WebhookService.getWebhookClient(message.channel); const webhookClient = await WebhookService.getWebhookClient(message.channel);
if (webhookClient) { if (webhookClient) {
// Send modified message copying the user's name and avatar
await webhookClient.send({ await webhookClient.send({
content, content,
username: message.member?.displayName || message.author.username, username: message.member?.displayName || message.author.username,
avatarURL: message.author.displayAvatarURL(), avatarURL: message.author.displayAvatarURL(),
}); });
// Delete the original message silently
if (message.deletable) { if (message.deletable) {
await message.delete(); await message.delete();
} }

View File

@ -130,7 +130,7 @@ export class SetupWizardRenderer {
embed.setTitle(t(locale, 'commands.setup.step4.title')) embed.setTitle(t(locale, 'commands.setup.step4.title'))
.setDescription(t(locale, 'commands.setup.step4.desc')); .setDescription(t(locale, 'commands.setup.step4.desc'));
const categories: ('SYSTEM' | 'VOICE' | 'PERMISSION' | 'INVITE' | 'MIMIC')[] = ['SYSTEM', 'VOICE', 'PERMISSION', 'INVITE', 'MIMIC']; const categories: ('SYSTEM' | 'VOICE' | 'PERMISSION' | 'INVITE')[] = ['SYSTEM', 'VOICE', 'PERMISSION', 'INVITE'];
const row1 = new ActionRowBuilder<ButtonBuilder>(); const row1 = new ActionRowBuilder<ButtonBuilder>();
categories.forEach(cat => { categories.forEach(cat => {
@ -200,7 +200,7 @@ export class SetupWizardRenderer {
// 감사 로그 카테고리 요약 // 감사 로그 카테고리 요약
let catStr = 'None'; let catStr = 'None';
if (audit?.channelId) { if (audit?.channelId) {
const allCats: ('SYSTEM' | 'VOICE' | 'PERMISSION' | 'INVITE' | 'MIMIC')[] = ['SYSTEM', 'VOICE', 'PERMISSION', 'INVITE', 'MIMIC']; const allCats: ('SYSTEM' | 'VOICE' | 'PERMISSION' | 'INVITE')[] = ['SYSTEM', 'VOICE', 'PERMISSION', 'INVITE'];
const enabled = allCats.filter(c => !audit.disabledCategories.includes(c)); const enabled = allCats.filter(c => !audit.disabledCategories.includes(c));
catStr = enabled.map(c => t(locale, `commands.setup.auditCategories.${c}`)).join(', '); catStr = enabled.map(c => t(locale, `commands.setup.auditCategories.${c}`)).join(', ');
} }