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:
parent
b81bc6b146
commit
4246eb90a5
|
|
@ -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)'**이라는 고유 명사를 프로젝트 전반(코드, 번역, 문서)에서 사용합니다.
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "GuildConfig" ADD COLUMN "bigEmojiEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ALTER COLUMN "mimicEnabled" SET DEFAULT false;
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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: '비활성화',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(', ');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue