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,8 +10,9 @@ datasource db {
|
|||
model GuildConfig {
|
||||
guildId String @id
|
||||
prefix String @default("!")
|
||||
mimicEnabled Boolean @default(true)
|
||||
locale String?
|
||||
mimicEnabled Boolean @default(false)
|
||||
bigEmojiEnabled Boolean @default(false)
|
||||
locale String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ export class KordClient extends Client {
|
|||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildVoiceStates,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
// GatewayIntentBits.MessageContent, // Privileged -> Disabled for testing
|
||||
// GatewayIntentBits.GuildMembers, // Privileged -> Disabled for testing
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildInvites,
|
||||
],
|
||||
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 { MimicService } from '../services/MimicService';
|
||||
import { BigEmojiService } from '../services/BigEmojiService';
|
||||
import { prisma } from '../database';
|
||||
|
||||
export default {
|
||||
name: Events.MessageCreate,
|
||||
once: false,
|
||||
async execute(message: Message) {
|
||||
await MimicService.handleMessage(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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,12 +11,18 @@ export const loadCommands = async (client: KordClient) => {
|
|||
|
||||
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.`);
|
||||
try {
|
||||
const module = require(filePath);
|
||||
const command = module.default || module;
|
||||
|
||||
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.`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to load command at ${filePath}:`, err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -218,7 +218,6 @@ export const en: TranslationSchema = {
|
|||
VOICE: 'Voice',
|
||||
PERMISSION: 'Permission',
|
||||
INVITE: 'Invite',
|
||||
MIMIC: 'Mimic',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -245,4 +244,18 @@ export const en: TranslationSchema = {
|
|||
managing: 'Managing Temp Voice Channels',
|
||||
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: '다음 단계'
|
||||
},
|
||||
step4: {
|
||||
title: '3-1️⃣ 감사 로그 카테고리',
|
||||
desc: '수신할 로그 카테고리를 선택하세요. 버튼 색상이 **초록색**이면 수신, **빨간색**이면 차단 상태입니다.',
|
||||
title: '감사 로그 카테고리 설정',
|
||||
desc: '로그를 수신할 카테고리를 선택해주세요.',
|
||||
nextBtn: '다음 단계',
|
||||
},
|
||||
step5: {
|
||||
|
|
@ -218,7 +218,6 @@ export const ko: TranslationSchema = {
|
|||
VOICE: '음성',
|
||||
PERMISSION: '권한',
|
||||
INVITE: '초대',
|
||||
MIMIC: '흉내',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -245,4 +244,18 @@ export const ko: TranslationSchema = {
|
|||
managing: '임시 음성 채널 관리 중',
|
||||
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;
|
||||
PERMISSION: 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: {
|
||||
|
|
|
|||
|
|
@ -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 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')) {
|
||||
// Feature: Word Mimic
|
||||
if (content.toLowerCase().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
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ export class SetupWizardRenderer {
|
|||
embed.setTitle(t(locale, 'commands.setup.step4.title'))
|
||||
.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>();
|
||||
|
||||
categories.forEach(cat => {
|
||||
|
|
@ -200,7 +200,7 @@ export class SetupWizardRenderer {
|
|||
// 감사 로그 카테고리 요약
|
||||
let catStr = 'None';
|
||||
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));
|
||||
catStr = enabled.map(c => t(locale, `commands.setup.auditCategories.${c}`)).join(', ');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue