Compare commits

..

No commits in common. "c71d2636075a659b9711b7f277016eafdffad90d" and "e4bcf533087bcbea190667b5b46a559815ed2afe" have entirely different histories.

12 changed files with 4395 additions and 7859 deletions

View File

@ -1,29 +0,0 @@
-- CreateEnum
CREATE TYPE "ExcludeType" AS ENUM ('ROLE', 'USER');
-- CreateTable
CREATE TABLE "AutoRoleConfig" (
"guildId" TEXT NOT NULL,
"userRoleId" TEXT,
"botRoleId" TEXT,
"isEnabled" BOOLEAN NOT NULL DEFAULT false,
"botEnabled" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutoRoleConfig_pkey" PRIMARY KEY ("guildId")
);
-- CreateTable
CREATE TABLE "AutoRoleExclude" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"type" "ExcludeType" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AutoRoleExclude_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AutoRoleExclude_guildId_targetId_type_key" ON "AutoRoleExclude"("guildId", "targetId", "type");

View File

@ -208,31 +208,6 @@ model FeverState {
updatedAt DateTime @updatedAt
}
model AutoRoleConfig {
guildId String @id
userRoleId String?
botRoleId String?
isEnabled Boolean @default(false)
botEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AutoRoleExclude {
id String @id @default(uuid())
guildId String
targetId String // Role ID or User ID
type ExcludeType
createdAt DateTime @default(now())
@@unique([guildId, targetId, type])
}
enum ExcludeType {
ROLE
USER
}
model RefinementLevelConfig {
level Int @id
successRate Float

View File

@ -17,7 +17,6 @@ export class KordClient extends Client {
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildInvites,
GatewayIntentBits.GuildMembers,
],
partials: [Partials.Message, Partials.Channel, Partials.GuildMember],
});

View File

@ -1,103 +0,0 @@
import {
SlashCommandBuilder,
ChatInputCommandInteraction,
PermissionFlagsBits,
EmbedBuilder,
Colors,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
RoleSelectMenuBuilder,
ComponentType
} from 'discord.js';
import { Command, CommandTrait } from '../core/command';
import { autoRoleService } from '../services/AutoRoleService';
import { t, SupportedLocale } from '../i18n';
class AutoRoleCommand extends Command {
protected override readonly trait = CommandTrait.General;
protected override guildOnly = true;
protected override define() {
return new SlashCommandBuilder()
.setName('autorole')
.setDescription('Configure automatic role assignment upon joining.')
.setDescriptionLocalizations({
ko: '입장 시 역할을 자동으로 부여하는 기능을 설정합니다.',
})
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator);
}
protected override async handle(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
const guild = interaction.guild!;
const config = await autoRoleService.getConfig(guild.id);
const embed = new EmbedBuilder()
.setTitle(t(locale, 'commands.autorole.statusTitle'))
.setColor(Colors.Blue)
.addFields(
{
name: t(locale, 'commands.autorole.userRoleLabel'),
value: config?.userRoleId ? `<@&${config.userRoleId}>` : t(locale, 'commands.autorole.notSet'),
inline: true
},
{
name: t(locale, 'commands.autorole.botRoleLabel'),
value: config?.botRoleId ? `<@&${config.botRoleId}>` : t(locale, 'commands.autorole.notSet'),
inline: true
},
{
name: t(locale, 'commands.autorole.statusLabel'),
value: config?.isEnabled ? `${t(locale, 'commands.autorole.enabled')}` : `${t(locale, 'commands.autorole.disabled')}`,
inline: true
},
{
name: t(locale, 'commands.autorole.botStatusLabel'),
value: config?.botEnabled ? `${t(locale, 'commands.autorole.enabled')}` : `${t(locale, 'commands.autorole.disabled')}`,
inline: true
}
);
const row1 = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('autorole_toggle')
.setLabel(t(locale, 'commands.autorole.statusLabel'))
.setStyle(config?.isEnabled ? ButtonStyle.Danger : ButtonStyle.Success),
new ButtonBuilder()
.setCustomId('autorole_toggle_bot')
.setLabel(t(locale, 'commands.autorole.botStatusLabel'))
.setStyle(config?.botEnabled ? ButtonStyle.Danger : ButtonStyle.Success)
);
const row2 = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('autorole_set_user')
.setLabel(t(locale, 'commands.autorole.userRoleLabel'))
.setStyle(ButtonStyle.Primary),
new ButtonBuilder()
.setCustomId('autorole_set_bot')
.setLabel(t(locale, 'commands.autorole.botRoleLabel'))
.setStyle(ButtonStyle.Primary)
);
const row3 = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('autorole_retroactive')
.setLabel(t(locale, 'commands.autorole.retroactiveBtn'))
.setStyle(ButtonStyle.Secondary)
.setDisabled(!config?.userRoleId),
new ButtonBuilder()
.setCustomId('autorole_exclude')
.setLabel(t(locale, 'commands.autorole.excludeTitle'))
.setStyle(ButtonStyle.Secondary)
);
await interaction.reply({
embeds: [embed],
components: [row1, row2, row3],
ephemeral: true,
});
}
}
export default new AutoRoleCommand().toModule();

View File

@ -1,192 +0,0 @@
import {
SlashCommandBuilder,
ChatInputCommandInteraction,
PermissionFlagsBits,
EmbedBuilder,
Colors,
RoleSelectMenuBuilder,
ActionRowBuilder
} from 'discord.js';
import { Command, CommandTrait } from '../core/command';
import { prisma } from '../database';
import { t, SupportedLocale } from '../i18n';
import { InviteService } from '../services/InviteService';
import { auditLogService } from '../services/AuditLogService';
class InviteCommand extends Command {
protected override readonly trait = CommandTrait.General;
protected override guildOnly = true;
protected override define() {
return new SlashCommandBuilder()
.setName('invite')
.setDescription('Manage roles mapped to invite codes.')
.setDescriptionLocalizations({
ko: '초대 코드와 역할을 연동하여 관리합니다.',
})
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription('List all invite codes in the server.')
.setDescriptionLocalizations({ ko: '서버의 초대 코드 목록을 조회합니다.' })
.addStringOption(option =>
option.setName('filter')
.setDescription('Lookup filter')
.addChoices(
{ name: 'all', value: 'all' },
{ name: 'managed', value: 'managed' }
)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('link')
.setDescription('Link a role to an existing invite code.')
.setDescriptionLocalizations({ ko: '기존 초대 코드에 역할을 연동합니다.' })
.addStringOption(option =>
option.setName('code')
.setDescription('Invite code string')
.setRequired(true)
)
.addRoleOption(option =>
option.setName('role')
.setDescription('Role to assign')
.setRequired(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('create')
.setDescription('Create a new invite code with a mapped role.')
.setDescriptionLocalizations({ ko: '역할이 연동된 새로운 초대 코드를 생성합니다.' })
.addRoleOption(option =>
option.setName('role')
.setDescription('Role to assign')
.setRequired(true)
)
.addIntegerOption(option =>
option.setName('max_uses')
.setDescription('Maximum uses')
)
.addIntegerOption(option =>
option.setName('max_age')
.setDescription('Expiration (seconds)')
)
)
.addSubcommand(subcommand =>
subcommand
.setName('unlink')
.setDescription('Unlink a role from an invite code.')
.setDescriptionLocalizations({ ko: '초대 코드에 연동된 역할을 해제합니다.' })
.addStringOption(option =>
option.setName('code')
.setDescription('Invite code string')
.setRequired(true)
)
);
}
protected override async handle(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
const subcommand = interaction.options.getSubcommand();
const guild = interaction.guild!;
if (subcommand === 'list') {
const filter = interaction.options.getString('filter') || 'managed';
const mappings = await prisma.inviteRole.findMany({ where: { guildId: guild.id } });
let displayInvites: any[] = [];
if (filter === 'all') {
const invites = await guild.invites.fetch();
displayInvites = invites.map(inv => ({
code: inv.code,
roleId: mappings.find(m => m.inviteCode === inv.code)?.roleId,
uses: inv.uses,
maxUses: inv.maxUses,
}));
} else {
displayInvites = mappings.map(m => ({
code: m.inviteCode,
roleId: m.roleId,
}));
}
if (displayInvites.length === 0) {
await interaction.reply({ content: t(locale, 'commands.invite.listEmpty'), ephemeral: true });
return;
}
const embed = new EmbedBuilder()
.setTitle(t(locale, 'commands.invite.listTitle'))
.setColor(Colors.Blue)
.setDescription(displayInvites.map(inv => `\`${inv.code}\`: ${inv.roleId ? `<@&${inv.roleId}>` : t(locale, 'autorole.notSet')}`).join('\n'));
await interaction.reply({ embeds: [embed], ephemeral: true });
return;
}
if (subcommand === 'link') {
const code = interaction.options.getString('code', true);
const role = interaction.options.getRole('role', true);
await prisma.inviteRole.upsert({
where: {
guildId_inviteCode: {
guildId: guild.id,
inviteCode: code,
},
},
update: { roleId: role.id },
create: {
guildId: guild.id,
inviteCode: code,
roleId: role.id,
},
});
await InviteService.cacheGuildInvites(guild);
await interaction.reply({ content: t(locale, 'commands.invite.linkSuccess', { code, role: role.name }), ephemeral: true });
return;
}
if (subcommand === 'create') {
const role = interaction.options.getRole('role', true);
const maxUses = interaction.options.getInteger('max_uses') || 0;
const maxAge = interaction.options.getInteger('max_age') || 0;
const invite = await guild.invites.create(interaction.channelId, {
maxUses,
maxAge,
unique: true,
});
await prisma.inviteRole.create({
data: {
guildId: guild.id,
inviteCode: invite.code,
roleId: role.id,
},
});
await InviteService.cacheGuildInvites(guild);
await interaction.reply({ content: t(locale, 'commands.invite.createSuccess', { code: invite.code, role: role.name }), ephemeral: true });
return;
}
if (subcommand === 'unlink') {
const code = interaction.options.getString('code', true);
await prisma.inviteRole.deleteMany({
where: {
guildId: guild.id,
inviteCode: code,
},
});
await InviteService.cacheGuildInvites(guild);
await interaction.reply({ content: t(locale, 'commands.invite.unlinkSuccess', { code }), ephemeral: true });
return;
}
}
}
export default new InviteCommand().toModule();

View File

@ -143,66 +143,6 @@ export default {
}, locale);
}
}
else if (interaction.isButton() && interaction.customId.startsWith('autorole_')) {
const locale = await getInteractionLocale(interaction);
const { autoRoleService } = require('../services/AutoRoleService');
await withErrorHandler(interaction, async () => {
// 타임아웃 방지를 위해 즉시 승인
await interaction.deferUpdate();
const guild = interaction.guild!;
if (interaction.customId === 'autorole_toggle') {
const config = await autoRoleService.getConfig(guild.id);
const newEnabled = !config?.isEnabled;
await autoRoleService.setEnabled(guild.id, newEnabled);
await interaction.editReply({ content: t(locale, 'commands.autorole.updateSuccess'), embeds: [], components: [] });
} else if (interaction.customId === 'autorole_toggle_bot') {
const config = await autoRoleService.getConfig(guild.id);
const newBotEnabled = !config?.botEnabled;
await autoRoleService.updateConfig(guild.id, { botEnabled: newBotEnabled });
await interaction.editReply({ content: t(locale, 'commands.autorole.updateSuccess'), embeds: [], components: [] });
} else if (interaction.customId === 'autorole_retroactive') {
const config = await autoRoleService.getConfig(guild.id);
if (config?.userRoleId) {
await autoRoleService.applyRetroactively(guild, config.userRoleId, interaction.user.id);
await interaction.editReply({ content: t(locale, 'commands.autorole.retroactiveStarted'), embeds: [], components: [] });
}
} else if (interaction.customId === 'autorole_set_user' || interaction.customId === 'autorole_set_bot') {
const isBot = interaction.customId === 'autorole_set_bot';
const { RoleSelectMenuBuilder } = require('discord.js');
const roleSelect = new RoleSelectMenuBuilder()
.setCustomId(`autorole_select_${isBot ? 'bot' : 'user'}`)
.setPlaceholder(isBot ? t(locale, 'commands.autorole.botRoleLabel') : t(locale, 'commands.autorole.userRoleLabel'))
.setMaxValues(1);
const selectRow = new ActionRowBuilder<any>().addComponents(roleSelect);
await interaction.editReply({ components: [selectRow] });
}
// 나머지 버튼 처리
}, locale);
}
else if (interaction.isRoleSelectMenu() && interaction.customId.startsWith('autorole_select_')) {
const locale = await getInteractionLocale(interaction);
const { autoRoleService } = require('../services/AutoRoleService');
await withErrorHandler(interaction, async () => {
// 타임아웃 방지를 위해 즉시 승인
await interaction.deferUpdate();
const guild = interaction.guild!;
const isBot = interaction.customId.includes('bot');
const roleId = interaction.values[0];
await autoRoleService.updateConfig(guild.id, {
[isBot ? 'botRoleId' : 'userRoleId']: roleId
});
await interaction.editReply({
content: t(locale, 'commands.autorole.updateSuccess'),
embeds: [],
components: []
});
}, locale);
}
else if (interaction.isModalSubmit()) {
const customId = interaction.customId;
if (customId.startsWith('modal_vc_')) {

View File

@ -1,4 +1,4 @@
import { TranslationSchema } from '../types';
import { TranslationSchema } from '../types';
/**
* English translations ??the DEFAULT and FALLBACK locale.
@ -191,46 +191,6 @@ export const en: TranslationSchema = {
status: 'Status',
},
},
autorole: {
description: 'Configure automatic role assignment upon joining.',
statusTitle: 'Auto Role Configuration Status',
userRoleLabel: 'User Role',
botRoleLabel: 'Bot Role',
statusLabel: 'User Auto Assignment',
botStatusLabel: 'Bot Auto Assignment',
notSet: 'Not Set',
enabled: 'Enabled',
disabled: 'Disabled',
updateSuccess: 'Auto role settings have been updated.',
retroactiveBtn: 'Apply Retroactively to Entire Server Now',
retroactiveConfirm: 'Do you want to scan all server members and assign roles? This may take time depending on the member count.',
retroactiveStarted: 'Retroactive assignment has started in the background. Check progress in audit logs.',
excludeTitle: 'Retroactive Exclude Settings',
excludeDesc: 'Users with specific IDs or roles will be excluded from retroactive assignment.',
excludeAddBtn: 'Add Exclude Target',
permissionsError: 'Failed to assign role due to low bot hierarchy or missing permissions.',
suspendNotice: 'Auto role assignment has been suspended due to insufficient permissions. Please check the bot\'s permissions and role hierarchy.',
},
invite: {
description: 'Manage roles mapped to invite codes.',
listDescription: 'List all invite codes in the server.',
linkDescription: 'Link a role to an existing invite code.',
createDescription: 'Create a new invite code with a mapped role.',
unlinkDescription: 'Unlink a role from an invite code.',
codeOption: 'Invite code string',
roleOption: 'Role to assign',
usesOption: 'Maximum uses',
ageOption: 'Expiration (seconds, 0=unlimited)',
filterOption: 'Lookup filter (all=all, managed=managed only)',
listTitle: 'Invite Code Mappings',
listEmpty: 'No invite codes are currently linked to roles.',
linkSuccess: 'Role {{role}} has been linked to invite code `{{code}}`.',
unlinkSuccess: 'Role link has been removed from invite code `{{code}}`.',
createSuccess: 'New invite code `{{code}}` has been created and linked to role {{role}}.',
expireWarning: 'Invite code `{{code}}` has expired or been deleted. Role link has been removed.',
identifyFail: 'Could not identify the invite code for the joining user.',
identifyFailDesc: 'Due to simultaneous joins, the invite code for {{user}} could not be determined. Only default roles were assigned.',
},
music: {
description: 'Play YouTube audio in voice channels.',
addDescription: 'Search YouTube or add a video URL to the queue.',

View File

@ -1,236 +1,196 @@
import { TranslationSchema } from '../types';
import { TranslationSchema } from '../types';
/**
* .
* en.ts와 1:1 .
* ??? ?.
* ?? en.ts?€ 1:1 ?€?? ???
*/
export const ko: TranslationSchema = {
// ── 에러 메시지 ─────────────────────────────────────────
// ?€?€ ?먮윭 硫붿떆吏€ ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
errors: {
E1001: {
userMessage: '사용자 제한 값이 올바르지 않습니다.',
resolution: '0에서 99 사이의 숫자를 입력해주세요. (0 = 무제한)',
resolution: '0?먯꽌 99 ?ъ씠???レ옄瑜??낅젰?댁<?몄슂. (0 = 臾댁젣??',
},
E1002: {
userMessage: '채널 이름 형식이 올바르지 않습니다.',
resolution: '유효한 채널 이름을 입력해주세요. (최대 100자)',
userMessage: '梨꾨꼸 ?대쫫 ?뺤떇???щ컮瑜댁? ?딆뒿?덈떎.',
resolution: '?좏슚??梨꾨꼸 ?대쫫???낅젰?댁<?몄슂. (理쒕? 100??',
},
E1003: {
userMessage: '자기 자신에게는 이 작업을 수행할 수 없습니다.',
userMessage: '?먭린 ?먯떊?먭쾶?????묒뾽???섑뻾?????놁뒿?덈떎.',
},
E1004: {
userMessage: '선택한 유저가 음성 채널에 없습니다.',
resolution: '작업을 수행하기 전에 해당 유저가 채널에 있는지 확인해주세요.',
userMessage: '?좏깮???좎?媛€ ?뚯꽦 梨꾨꼸???놁뒿?덈떎.',
resolution: '?묒뾽???섑뻾?섍린 ?꾩뿉 ?대떦 ?좎?媛€ 梨꾨꼸???덈뒗吏€ ?뺤씤?댁<?몄슂.',
},
E2001: {
userMessage: '봇에 채널을 관리할 권한이 부족합니다.',
resolution: '서버 관리자에게 봇의 "채널 관리" 권한을 확인해달라고 요청하세요.',
userMessage: '遊뉗뿉 梨꾨꼸??愿€由ы븷 沅뚰븳??遺€議깊빀?덈떎.',
resolution: '?쒕쾭 愿€由ъ옄?먭쾶 遊뉗쓽 "梨꾨꼸 愿€由? 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??',
},
E2002: {
userMessage: '봇에 음성 채널 관련 권한이 부족합니다.',
resolution: '서버 관리자에게 봇의 "채널 관리", "역할 관리", "멤버 이동" 권한을 확인해달라고 요청하세요.',
userMessage: '遊뉗뿉 ?뚯꽦 梨꾨꼸 愿€??沅뚰븳??遺€議깊빀?덈떎.',
resolution: '?쒕쾭 愿€由ъ옄?먭쾶 遊뉗쓽 "梨꾨꼸 愿€由?, "??븷 愿€由?, "硫ㅻ쾭 ?대룞" 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??',
},
E2003: {
userMessage: '이 명령어를 사용할 권한이 없습니다.',
resolution: '이 명령어는 관리자 권한이 필요합니다.',
userMessage: '??紐낅졊?대? ?ъ슜??沅뚰븳???놁뒿?덈떎.',
resolution: '??紐낅졊?대뒗 愿€由ъ옄 沅뚰븳???꾩슂?⑸땲??',
},
E2004: {
userMessage: '채널 소유자만 이 기능을 사용할 수 있습니다.',
userMessage: '梨꾨꼸 ?뚯쑀?먮쭔 ??湲곕뒫???ъ슜?????덉뒿?덈떎.',
},
E2005: {
userMessage: '활성된 임시 음성 채널에 참여 중이어야 사용할 수 있습니다.',
resolution: '임시 음성 채널에 참여한 뒤 다시 시도해주세요.',
userMessage: '?쒖꽦???꾩떆 ?뚯꽦 梨꾨꼸??李몄뿬 以묒씠?댁빞 ?ъ슜?????덉뒿?덈떎.',
resolution: '?꾩떆 ?뚯꽦 梨꾨꼸??李몄뿬?????ㅼ떆 ?쒕룄?댁<?몄슂.',
},
E3001: {
userMessage: '요청을 처리하는 중 내부 오류가 발생했습니다.',
resolution: '잠시 후 다시 시도해주세요. 문제가 지속되면 봇 관리자에게 문의하세요.',
userMessage: '?붿껌??泥섎━?섎뒗 以??대? ?ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.',
resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛€ 吏€?띾릺硫?遊?愿€由ъ옄?먭쾶 臾몄쓽?섏꽭??',
},
E3002: {
userMessage: '요청을 처리하는 중 내부 오류가 발생했습니다.',
resolution: '잠시 후 다시 시도해주세요.',
userMessage: '?붿껌??泥섎━?섎뒗 以??대? ?ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.',
resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂.',
},
E3003: {
userMessage: '명령어를 실행하는 중 오류가 발생했습니다.',
resolution: '다시 시도해주세요. 문제가 지속되면 봇 관리자에게 문의하세요.',
userMessage: '紐낅졊?대? ?ㅽ뻾?섎뒗 以??ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.',
resolution: '?ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛€ 吏€?띾릺硫?遊?愿€由ъ옄?먭쾶 臾몄쓽?섏꽭??',
},
E3999: {
userMessage: '예상치 못한 오류가 발생했습니다.',
resolution: '나중에 다시 시도해주세요. 문제가 계속되면 봇 관리자에게 문의하세요.',
userMessage: '?덉긽移?紐삵븳 ?ㅻ쪟媛€ 諛쒖깮?덉뒿?덈떎.',
resolution: '?섏쨷???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛€ 怨꾩냽?섎㈃ 遊?愿€由ъ옄?먭쾶 臾몄쓽?섏꽭??',
},
E4001: {
userMessage: 'Discord에 의해 요청이 제한되었습니다.',
resolution: '잠시 기다린 후 다시 시도해주세요.',
userMessage: 'Discord???섑빐 ?붿껌???쒗븳?섏뿀?듬땲??',
resolution: '?좎떆 湲곕떎由????ㅼ떆 ?쒕룄?댁<?몄슂.',
},
E4002: {
userMessage: '권한 부족으로 Discord가 작업을 거부했습니다.',
resolution: '서버 관리자에게 봇의 역할 및 채널 권한을 확인해달라고 요청하세요.',
userMessage: '沅뚰븳 遺€議깆쑝濡?Discord媛€ ?묒뾽??嫄곕??덉뒿?덈떎.',
resolution: '?쒕쾭 愿€由ъ옄?먭쾶 遊뉗쓽 ??븷 諛?梨꾨꼸 沅뚰븳???뺤씤?대떖?쇨퀬 ?붿껌?섏꽭??',
},
E4003: {
userMessage: 'Discord에 일시적인 문제가 발생했습니다.',
resolution: '잠시 후 다시 시도해주세요. 문제가 지속되면 https://discordstatus.com 에서 상태를 확인해주세요.',
userMessage: 'Discord???쇱떆?곸씤 臾몄젣媛€ 諛쒖깮?덉뒿?덈떎.',
resolution: '?좎떆 ???ㅼ떆 ?쒕룄?댁<?몄슂. 臾몄젣媛€ 吏€?띾릺硫?https://discordstatus.com ?먯꽌 ?곹깭瑜??뺤씤?댁<?몄슂.',
},
},
// ── 에러 카테고리 타이틀 ────────────────────────────────
// ?€?€ ?먮윭 移댄뀒怨좊━ ?€?댄? ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
errorTitles: {
USER_INPUT: '입력을 확인해주세요',
PERMISSION: '권한이 부족합니다',
BOT_INTERNAL: '문제가 발생했습니다',
DISCORD_API: '일시적인 문제입니다',
PERMISSION: '沅뚰븳??遺€議깊빀?덈떎',
BOT_INTERNAL: '臾몄젣媛€ 諛쒖깮?덉뒿?덈떎',
DISCORD_API: '일시적인 문제입니다.',
},
// ── 에러 Embed 필드 라벨 ────────────────────────────────
// ?€?€ ?먮윭 Embed ?꾨뱶 ?쇰꺼 ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
errorFields: {
resolution: '💡 해결 방법',
},
// ── 음성 채널 ───────────────────────────────────────────
// ?€?€ ?뚯꽦 梨꾨꼸 ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
voice: {
channelReady: '{{owner}}, 임시 채널이 준비되었습니다! 아래 드롭다운 메뉴로 관리하세요.',
defaultRoomName: '{{username}}의 방',
controlPanel: {
placeholder: '⚙️ 채널 설정 관리',
placeholder: '채널 설정 관리',
rename: '채널 이름 변경',
limit: '인원 제한 설정',
lock: '채널 잠금 / 해제',
kick: '유저 추방',
limit: '?몄썝 ?쒗븳 ?ㅼ젙',
lock: '梨꾨꼸 ?좉툑 / ?댁젣',
kick: '?좎? 異붾갑',
ban: '유저 차단 / 숨기기',
transfer: '소유권 이전',
transfer: '?뚯쑀沅??댁쟾',
},
responses: {
channelLocked: '채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.',
channelUnlocked: '채널이 해제되었습니다! 누구나 참여할 수 있습니다.',
channelRenamed: '채널 이름이 **{{name}}**(으)로 변경되었습니다!',
limitSet: '인원 제한이 **{{limit}}**명으로 설정되었습니다!',
channelUnlocked: '梨꾨꼸???댁젣?섏뿀?듬땲?? ?꾧뎄??李몄뿬?????덉뒿?덈떎.',
channelRenamed: '梨꾨꼸 ?대쫫??**{{name}}**(??濡?蹂€寃쎈릺?덉뒿?덈떎!',
limitSet: '?몄썝 ?쒗븳??**{{limit}}**紐낆쑝濡??ㅼ젙?섏뿀?듬땲??',
limitUnlimited: '무제한',
kicked: '{{user}}을(를) 채널에서 추방했습니다.',
banned: '{{user}}에게 채널을 숨기고 차단했습니다.',
transferPrompt: '채널의 새 소유자를 선택하세요.',
transferDone: '소유권이 {{user}}에게 이전되었습니다.',
banPrompt: '차단하면 해당 유저에게 채널이 보이지 않게 됩니다.',
kicked: '{{user}}??瑜? 梨꾨꼸?먯꽌 異붾갑?덉뒿?덈떎.',
banned: '{{user}}?먭쾶 梨꾨꼸???④린怨?李⑤떒?덉뒿?덈떎.',
transferPrompt: '梨꾨꼸?????뚯쑀?먮? ?좏깮?섏꽭??',
transferDone: '?뚯쑀沅뚯씠 {{user}}?먭쾶 ?댁쟾?섏뿀?듬땲??',
banPrompt: '李⑤떒?섎㈃ ?대떦 ?좎??먭쾶 梨꾨꼸??蹂댁씠吏€ ?딄쾶 ?⑸땲??',
},
},
// ── 명령어 ──────────────────────────────────────────────
// ?€?€ 紐낅졊???€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
commands: {
voiceSetup: {
description: '임시 음성 채널을 위한 생성기 채널을 설정합니다.',
setDescription: '기존 음성 채널을 생성기로 설정합니다',
createDescription: '새 음성 채널을 만들고 생성기로 설정합니다',
channelOptionDescription: '생성기로 사용할 음성 채널',
categoryOptionDescription: '(선택) 임시 채널이 생성될 카테고리',
nameOptionDescription: '새 생성기 음성 채널의 이름',
setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!',
createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성 및 설정했습니다!',
description: '?꾩떆 ?뚯꽦 梨꾨꼸???꾪븳 ?앹꽦湲?梨꾨꼸???ㅼ젙?⑸땲??',
setDescription: '기존 음성 채널을 생성기로 설정합니다.',
createDescription: '새 음성 채널을 만들고 생성기로 설정합니다.',
channelOptionDescription: '?앹꽦湲곕줈 ?ъ슜???뚯꽦 梨꾨꼸',
categoryOptionDescription: '(?좏깮) ?꾩떆 梨꾨꼸???앹꽦??移댄뀒怨좊━',
nameOptionDescription: '???앹꽦湲??뚯꽦 梨꾨꼸???대쫫',
setSuccess: '{{channel}}??瑜? ?뚯꽦 ?앹꽦湲?梨꾨꼸濡??ㅼ젙?덉뒿?덈떎!',
createSuccess: '{{channel}}??瑜? ?뚯꽦 ?앹꽦湲?梨꾨꼸濡??앹꽦 諛??ㅼ젙?덉뒿?덈떎!',
},
voiceConfig: {
description: '서버의 임시 음성 채널 설정을 관리합니다.',
setNameTitle: '기본 이름 템플릿 설정',
setNameDesc: '임시 채널 생성 시 사용할 기본 이름 형식을 설정합니다. (사용자명: {{username}})',
setLimitTitle: '기본 인원 제한 설정',
setLimitDesc: '임시 채널 생성 시 적용할 기본 인원 제한을 설정합니다.',
statusTitle: '현재 서버 음성 설정',
description: '?쒕쾭???꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙??愿€由ы빀?덈떎.',
setNameTitle: '湲곕낯 ?대쫫 ?쒗뵆由??ㅼ젙',
setNameDesc: '?꾩떆 梨꾨꼸 ?앹꽦 ???ъ슜??湲곕낯 ?대쫫 ?뺤떇???ㅼ젙?⑸땲?? (?ъ슜?먮챸: {{username}})',
setLimitTitle: '湲곕낯 ?몄썝 ?쒗븳 ?ㅼ젙',
setLimitDesc: '?꾩떆 梨꾨꼸 ?앹꽦 ???곸슜??湲곕낯 ?몄썝 ?쒗븳???ㅼ젙?⑸땲??',
statusTitle: '?꾩옱 ?쒕쾭 ?뚯꽦 ?ㅼ젙',
templateLabel: '이름 템플릿',
limitLabel: '기본 인원 제한',
setSuccess: '서버의 임시 채널 설정이 업데이트되었습니다.',
limitValue: '{{limit}}명 (0 = 무제한)',
limitLabel: '湲곕낯 ?몄썝 ?쒗븳',
setSuccess: '?쒕쾭???꾩떆 梨꾨꼸 ?ㅼ젙???낅뜲?댄듃?섏뿀?듬땲??',
limitValue: '{{limit}}紐?(0 = 臾댁젣??',
},
language: {
description: '봇의 언어를 설정합니다.',
scopeDescription: '본인에게만 또는 서버 전체에 적용',
localeDescription: '사용할 언어',
scopeUser: '나만 적용',
scopeServer: '서버 전체 (관리자 전용)',
userSet: '개인 언어가 **{{locale}}**(으)로 설정되었습니다.',
serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.',
serverPermissionDenied: '서버 언어 변경은 서버 관리자만 가능합니다.',
description: '遊뉗쓽 ?몄뼱瑜??ㅼ젙?⑸땲??',
scopeDescription: '蹂몄씤?먭쾶留??먮뒗 ?쒕쾭 ?꾩껜???곸슜',
localeDescription: '?ъ슜???몄뼱',
scopeUser: '?섎쭔 ?곸슜',
scopeServer: '?쒕쾭 ?꾩껜 (愿€由ъ옄 ?꾩슜)',
userSet: '媛쒖씤 ?몄뼱媛€ **{{locale}}**(??濡??ㅼ젙?섏뿀?듬땲??',
serverSet: '?쒕쾭 ?몄뼱媛€ **{{locale}}**(??濡??ㅼ젙?섏뿀?듬땲??',
serverPermissionDenied: '?쒕쾭 ?몄뼱 蹂€寃쎌? ?쒕쾭 愿€由ъ옄留?媛€?ν빀?덈떎.',
},
event: {
description: '서버 이벤트 일정을 관리합니다.',
createDescription: '새 서버 이벤트를 생성합니다.',
listDescription: '예정된 서버 이벤트 목록을 조회합니다.',
cancelDescription: '예약된 서버 이벤트를 취소합니다.',
announceDescription: '이벤트 공지 Embed를 다시 게시합니다.',
titleDescription: '이벤트 제목',
dateDescription: 'YYYY-MM-DD 형식의 날짜',
timeDescription: 'HH:mm 형식의 시간 (24시간제, Asia/Seoul 기준)',
descriptionOptionDescription: '선택 사항인 이벤트 설명',
channelDescription: '선택 사항인 공지 채널',
reminderDescription: '리마인더 메시지 사용 여부',
remindersDescription: '분 단위 리마인더 목록, 예: 0,10,60',
idDescription: '취소할 이벤트 ID',
createSuccessTitle: '이벤트 생성 완료',
createSuccessBody: '**{{title}}** 이벤트가 예약되었습니다.',
listTitle: '예정된 이벤트 목록',
listEmpty: '예정된 이벤트가 없습니다.',
listItemValue: '**시작 시각:** {{startsAt}}\n**남은 시간:** {{relative}}\n**상태:** {{status}}\n**리마인더:** {{reminder}}\n**채널:** {{channel}}',
cancelSuccess: '`{{id}}` 이벤트를 취소했습니다.',
cancelNotFound: 'ID가 `{{id}}`인 예약 이벤트를 찾지 못했습니다.',
announceSuccess: '`{{id}}` 이벤트를 {{channel}} 채널에 공지했습니다.',
announceNotAvailable: '이 이벤트에는 사용할 수 있는 공지 채널이 설정되어 있지 않습니다.',
startAnnouncementTitle: '이벤트 시작',
startAnnouncementLead: '이 이벤트가 지금 시작됩니다.',
invalidDateTime: '이벤트 날짜 또는 시간 형식이 올바르지 않습니다.',
invalidDateTimeResolution: '날짜는 `YYYY-MM-DD`, 시간은 `HH:mm` 24시간 형식으로 입력해주세요.',
invalidReminderOffsets: '리마인더 분 입력 형식이 올바르지 않습니다.',
invalidReminderOffsetsResolution: '`0,10,60`처럼 0 이상의 분을 쉼표로 구분해 입력해주세요. 비워두면 자동 공지는 하지 않습니다.',
invalidPastDateTime: '과거 시각으로 이벤트를 예약할 수 없습니다.',
invalidPastDateTimeResolution: '미래 시각을 선택한 뒤 다시 시도해주세요.',
description: '?쒕쾭 ?대깽???쇱젙??愿€由ы빀?덈떎.',
createDescription: '???쒕쾭 ?대깽?몃? ?앹꽦?⑸땲??',
listDescription: '?덉젙???쒕쾭 ?대깽??紐⑸줉??議고쉶?⑸땲??',
cancelDescription: '?덉빟???쒕쾭 ?대깽?몃? 痍⑥냼?⑸땲??',
announceDescription: '?대깽??怨듭? Embed瑜??ㅼ떆 寃뚯떆?⑸땲??',
titleDescription: '?대깽???쒕ぉ',
dateDescription: 'YYYY-MM-DD ?뺤떇???좎쭨',
timeDescription: 'HH:mm ?뺤떇???쒓컙 (24?쒓컙?? Asia/Seoul 湲곗?)',
descriptionOptionDescription: '?좏깮 ?ы빆???대깽???ㅻ챸',
channelDescription: '?좏깮 ?ы빆??怨듭? 梨꾨꼸',
reminderDescription: '由щ쭏?몃뜑 硫붿떆吏€ ?ъ슜 ?щ?',
remindersDescription: '遺??⑥쐞 由щ쭏?몃뜑 紐⑸줉, ?? 0,10,60',
idDescription: '痍⑥냼???대깽??ID',
createSuccessTitle: '?대깽???앹꽦 ?꾨즺',
createSuccessBody: '**{{title}}** ?대깽?멸? ?덉빟?섏뿀?듬땲??',
listTitle: '?덉젙???대깽??紐⑸줉',
listEmpty: '?덉젙???대깽?멸? ?놁뒿?덈떎.',
listItemValue: '**?쒖옉 ?쒓컖:** {{startsAt}}\n**?⑥? ?쒓컙:** {{relative}}\n**?곹깭:** {{status}}\n**由щ쭏?몃뜑:** {{reminder}}\n**梨꾨꼸:** {{channel}}',
cancelSuccess: '`{{id}}` ?대깽?몃? 痍⑥냼?덉뒿?덈떎.',
cancelNotFound: 'ID媛€ `{{id}}`???덉빟 ?대깽?몃? 李얠? 紐삵뻽?듬땲??',
announceSuccess: '`{{id}}` ?대깽?몃? {{channel}} 梨꾨꼸??怨듭??덉뒿?덈떎.',
announceNotAvailable: '???대깽?몄뿉???ъ슜?????덈뒗 怨듭? 梨꾨꼸???ㅼ젙?섏뼱 ?덉? ?딆뒿?덈떎.',
startAnnouncementTitle: '?대깽???쒖옉',
startAnnouncementLead: '???대깽?멸? 吏€湲??쒖옉?⑸땲??',
invalidDateTime: '?대깽???좎쭨 ?먮뒗 ?쒓컙 ?뺤떇???щ컮瑜댁? ?딆뒿?덈떎.',
invalidDateTimeResolution: '?좎쭨??`YYYY-MM-DD`, ?쒓컙?€ `HH:mm` 24?쒓컙 ?뺤떇?쇰줈 ?낅젰?댁<?몄슂.',
invalidReminderOffsets: '由щ쭏?몃뜑 遺??낅젰 ?뺤떇???щ컮瑜댁? ?딆뒿?덈떎.',
invalidReminderOffsetsResolution: '`0,10,60`泥섎읆 0 ?댁긽??遺꾩쓣 ?쇳몴濡?援щ텇???낅젰?댁<?몄슂. 鍮꾩썙?먮㈃ ?먮룞 怨듭????섏? ?딆뒿?덈떎.',
invalidPastDateTime: '怨쇨굅 ?쒓컖?쇰줈 ?대깽?몃? ?덉빟?????놁뒿?덈떎.',
invalidPastDateTimeResolution: '誘몃옒 ?쒓컖???좏깮?????ㅼ떆 ?쒕룄?댁<?몄슂.',
statusScheduled: '예약됨',
statusCancelled: '취소됨',
statusCompleted: '완료됨',
reminderOn: '사용',
reminderOn: '?ъ슜',
reminderOff: '사용 안 함',
reminderNone: '자동 공지 없음',
reminderNone: '?먮룞 怨듭? ?놁쓬',
announcementChannelNone: '미설정',
fields: {
eventId: '이벤트 ID',
startsAt: '시작 시각',
reminder: '리마인더',
announcementChannel: '공지 채널',
status: '상태',
eventId: '?대깽??ID',
startsAt: '?쒖옉 ?쒓컖',
reminder: '由щ쭏?몃뜑',
announcementChannel: '怨듭? 梨꾨꼸',
status: '?곹깭',
},
},
autorole: {
description: '입장 시 역할을 자동으로 부여하는 기능을 설정합니다.',
statusTitle: '자동 역할 부여 설정 상태',
userRoleLabel: '일반 유저 역할',
botRoleLabel: '봇 역할',
statusLabel: '유저 자동 부여',
botStatusLabel: '봇 자동 부여',
notSet: '미설정',
enabled: '활성',
disabled: '비활성',
updateSuccess: '자동 역할 설정이 업데이트되었습니다.',
retroactiveBtn: '지금 서버 전체에 소급 적용',
retroactiveConfirm: '서버의 모든 멤버를 스캔하여 역할을 부여하시겠습니까? 멤버 수에 따라 시간이 걸릴 수 있습니다.',
retroactiveStarted: '백그라운드에서 소급 적용을 시작합니다. 진행 상황은 감사 로그에서 확인하세요.',
excludeTitle: '소급 적용 제외 설정',
excludeDesc: '특정 ID나 역할을 가진 유저는 소급 적용 대상에서 제외됩니다.',
excludeAddBtn: '제외 대상 추가',
permissionsError: '봇의 역할 순위가 낮거나 권한이 부족하여 역할을 부여할 수 없습니다.',
suspendNotice: '권한 부족으로 인해 자동 역할 부여 기능이 일시 중지되었습니다. 봇의 권한과 역할 순위를 확인해 주세요.',
},
invite: {
description: '초대 코드와 역할을 연동하여 관리합니다.',
listDescription: '서버의 초대 코드 목록을 조회합니다.',
linkDescription: '기존 초대 코드에 역할을 연동합니다.',
createDescription: '역할이 연동된 새로운 초대 코드를 생성합니다.',
unlinkDescription: '초대 코드에 연동된 역할을 해제합니다.',
codeOption: '초대 코드 문자열',
roleOption: '부여할 역할',
usesOption: '최대 사용 횟수',
ageOption: '만료 기간(초, 0=무제한)',
filterOption: '조회 필터 (all=전체, managed=관리 중)',
listTitle: '초대 코드 매핑 목록',
listEmpty: '연동된 초대 코드가 없습니다.',
linkSuccess: '초대 코드 `{{code}}`에 {{role}} 역할이 연동되었습니다.',
unlinkSuccess: '초대 코드 `{{code}}`의 역할 연동이 해제되었습니다.',
createSuccess: '새로운 초대 코드 `{{code}}`가 생성되었으며 {{role}} 역할이 연동되었습니다.',
expireWarning: '초대 코드 `{{code}}`가 만료/삭제되어 역할 연동이 해제되었습니다.',
identifyFail: '참여한 유저의 초대 코드를 식별하지 못했습니다.',
identifyFailDesc: '동시 접속 등의 이유로 {{user}}님의 초대 코드를 확정할 수 없어 기본 역할만 부여되었습니다.',
},
music: {
description: 'Play YouTube audio in voice channels.',
addDescription: 'Search YouTube or add a video URL to the queue.',
@ -337,112 +297,112 @@ export const ko: TranslationSchema = {
},
permissionAudit: {
title: '봇 권한 진단 보고서',
channel: '채널',
noResults: '진단할 기능이 없습니다. 봇이 아직 설정되지 않았을 수 있습니다.',
summaryLabel: '진단 결과 요약',
summaryOk: '✅ 모든 항목 정상. 문제가 없습니다.',
summaryIssue: '❌ {{fail}}개 실패 · ⚠️ {{warn}}개 경고 감지됨.',
hierarchyWarning: "봇 역할(순위: {{botPos}})이 '{{role}}'(순위: {{targetPos}})보다 위에 있어야 관리할 수 있습니다.",
channel: '梨꾨꼸',
noResults: '吏꾨떒??湲곕뒫???놁뒿?덈떎. 遊뉗씠 ?꾩쭅 ?ㅼ젙?섏? ?딆븯?????덉뒿?덈떎.',
summaryLabel: '吏꾨떒 寃곌낵 ?붿빟',
summaryOk: '??紐⑤뱺 ??ぉ ?뺤긽. 臾몄젣媛€ ?놁뒿?덈떎.',
summaryIssue: '??{{fail}}媛??ㅽ뙣 쨌 ?좑툘 {{warn}}媛?寃쎄퀬 媛먯???',
hierarchyWarning: "遊???븷(?쒖쐞: {{botPos}})??'{{role}}'(?쒖쐞: {{targetPos}})蹂대떎 ?꾩뿉 ?덉뼱??愿€由ы븷 ???덉뒿?덈떎.",
features: {
BASIC: '기본 봇 기능',
VOICE_GLOBAL: '임시 음성 채널 (전역)',
VOICE_GENERATOR_CHANNEL: '음성 생성기 채널',
VOICE_GENERATOR_CATEGORY: '음성 생성기 카테고리',
INVITE_TRACKING: '초대 추적',
INVITE_ROLE_HIERARCHY: '초대 역할 부여 (계층 검사)',
MIMIC_WEBHOOK: '메시지 흉내 (Webhook)',
BASIC: '湲곕낯 遊?湲곕뒫',
VOICE_GLOBAL: '?꾩떆 ?뚯꽦 梨꾨꼸 (?꾩뿭)',
VOICE_GENERATOR_CHANNEL: '?뚯꽦 ?앹꽦湲?梨꾨꼸',
VOICE_GENERATOR_CATEGORY: '?뚯꽦 ?앹꽦湲?移댄뀒怨좊━',
INVITE_TRACKING: '珥덈? 異붿쟻',
INVITE_ROLE_HIERARCHY: '珥덈? ??븷 遺€??(怨꾩링 寃€??',
MIMIC_WEBHOOK: '硫붿떆吏€ ?됰궡 (Webhook)',
},
},
setup: {
description: '설정 마법사를 실행하여 봇의 필수 기능들을 단계별로 설정합니다.',
description: '?ㅼ젙 留덈쾿?щ? ?ㅽ뻾?섏뿬 遊뉗쓽 ?꾩닔 湲곕뒫?ㅼ쓣 ?④퀎蹂꾨줈 ?ㅼ젙?⑸땲??',
step0: {
title: '✨ 봇 설정 마법사 시작',
desc: '환영합니다! 이 마법사를 통해 아래 4가지 항목을 설정합니다.\n\n1⃣ **언어 설정**\n2⃣ **필수 권한 점검**\n3⃣ **감사 채널 설정**\n4⃣ **임시 음성 채널 설정**',
startBtn: '설정 시작하기'
title: '??遊??ㅼ젙 留덈쾿???쒖옉',
desc: '?섏쁺?⑸땲?? ??留덈쾿?щ? ?듯빐 ?꾨옒 4媛€€ ??ぉ???ㅼ젙?⑸땲??\n\n1截뤴깵 **?몄뼱 ?ㅼ젙**\n2截뤴깵 **?꾩닔 沅뚰븳 ?먭?**\n3截뤴깵 **媛먯궗 梨꾨꼸 ?ㅼ젙**\n4截뤴깵 **?꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙**',
startBtn: '?ㅼ젙 ?쒖옉?섍린'
},
step1: {
title: '1️⃣ 언어 설정',
desc: '서버 전체에 적용될 봇의 기본 언어를 선택하세요. (현재: **{{locale}}**)',
title: '1截뤴깵 ?몄뼱 ?ㅼ젙',
desc: '?쒕쾭 ?꾩껜???곸슜??遊뉗쓽 湲곕낯 ?몄뼱瑜??좏깮?섏꽭?? (?꾩옱: **{{locale}}**)',
placeholder: '언어를 선택하세요',
nextBtn: '다음 단계',
skipBtn: '건너뛰기'
nextBtn: '?ㅼ쓬 ?④퀎',
skipBtn: '嫄대꼫?곌린'
},
step2: {
title: '2️⃣ 필수 권한 점검',
descOk: '✅ **모든 필수 권한이 정상적으로 부여되어 있습니다.**',
descFail: '⚠️ **일부 권한이 부족합니다.**\n결과를 확인하고 봇 역할에 필요한 기능 권한을 부여해주세요.',
title: '2截뤴깵 ?꾩닔 沅뚰븳 ?먭?',
descOk: '??**紐⑤뱺 ?꾩닔 沅뚰븳???뺤긽?곸쑝濡?遺€?щ릺???덉뒿?덈떎.**',
descFail: '?좑툘 **?쇰? 沅뚰븳??遺€議깊빀?덈떎.**\n寃곌낵瑜??뺤씤?섍퀬 遊???븷???꾩슂??湲곕뒫 沅뚰븳??遺€?ы빐二쇱꽭??',
recheckBtn: '다시 검사하기',
nextBtn: '다음 단계'
nextBtn: '?ㅼ쓬 ?④퀎'
},
step3: {
title: '3️⃣ 감사 채널 설정',
desc: '봇의 주요 이벤트와 에러 통보를 받을 채널을 선택해주세요.',
placeholder: '감사 통보 채널 선택',
disableBtn: '감사 채널 끄기/해제',
nextBtn: '다음 단계'
title: '3截뤴깵 媛먯궗 梨꾨꼸 ?ㅼ젙',
desc: '遊뉗쓽 二쇱슂 ?대깽?몄? ?먮윭 ?듬낫瑜?諛쏆쓣 梨꾨꼸???좏깮?댁<?몄슂.',
placeholder: '媛먯궗 ?듬낫 梨꾨꼸 ?좏깮',
disableBtn: '媛먯궗 梨꾨꼸 ?꾧린/?댁젣',
nextBtn: '?ㅼ쓬 ?④퀎'
},
step4: {
title: '감사 로그 카테고리 설정',
desc: '로그를 수신할 카테고리를 선택해주세요.',
nextBtn: '다음 단계',
title: '媛먯궗 濡쒓렇 移댄뀒怨좊━ ?ㅼ젙',
desc: '濡쒓렇瑜??섏떊??移댄뀒怨좊━瑜??좏깮?댁<?몄슂.',
nextBtn: '?ㅼ쓬 ?④퀎',
},
step5: {
title: '4️⃣ 임시 음성 채널 설정',
desc: '임시 음성 채널을 생성할 "생성기 채널"을 선택해주세요.\n기존의 채널을 고르거나 카테고리/채널을 봇이 **자동 생성**하게 할 수도 있습니다.',
placeholder: '생성기로 쓸 음성 채널 선택',
autoBtn: '🚀 자동 생성하기',
skipBtn: '임시 음성 사용 안함',
nextBtn: '설정 완료'
title: '4截뤴깵 ?꾩떆 ?뚯꽦 梨꾨꼸 ?ㅼ젙',
desc: '?꾩떆 ?뚯꽦 梨꾨꼸???앹꽦??"?앹꽦湲?梨꾨꼸"???좏깮?댁<?몄슂.\n湲곗〈??梨꾨꼸??怨좊Ⅴ嫄곕굹 移댄뀒怨좊━/梨꾨꼸??遊뉗씠 **?먮룞 ?앹꽦**?섍쾶 ???섎룄 ?덉뒿?덈떎.',
placeholder: '?앹꽦湲곕줈 ???뚯꽦 梨꾨꼸 ?좏깮',
autoBtn: '?? ?먮룞 ?앹꽦?섍린',
skipBtn: '?꾩떆 ?뚯꽦 ?ъ슜 ?덊븿',
nextBtn: '?ㅼ젙 ?꾨즺'
},
step6: {
title: '🎉 설정 완료 요약',
desc: '**1. 언어**: {{lang}}\n**2. 감사 채널**: {{audit}}\n**3. 감사 카테고리**: {{categories}}\n**4. 임시 음성 채널**: {{voice}}',
title: '?럦 ?ㅼ젙 ?꾨즺 ?붿빟',
desc: '**1. ?몄뼱**: {{lang}}\n**2. 媛먯궗 梨꾨꼸**: {{audit}}\n**3. 媛먯궗 移댄뀒怨좊━**: {{categories}}\n**4. ?꾩떆 ?뚯꽦 梨꾨꼸**: {{voice}}',
finishBtn: '마치기'
},
finished: '✅ 설정 마법사를 종료했습니다.',
expired: '⏳ 시간이 만료되었습니다. `/setup`을 다시 실행해주세요.',
defaultCategoryName: '음성 채널',
defaultGeneratorName: ' 채널 생성하기',
finished: '???ㅼ젙 留덈쾿?щ? 醫낅즺?덉뒿?덈떎.',
expired: '???쒓컙??留뚮즺?섏뿀?듬땲?? `/setup`???ㅼ떆 ?ㅽ뻾?댁<?몄슂.',
defaultCategoryName: '?뚯꽦 梨꾨꼸',
defaultGeneratorName: '??梨꾨꼸 ?앹꽦?섍린',
auditCategories: {
SYSTEM: '시스템',
BOOT: '부팅',
VOICE: '음성',
PERMISSION: '권한',
INVITE: '초대',
VOICE: '?뚯꽦',
PERMISSION: '沅뚰븳',
INVITE: '珥덈?',
},
},
config: {
title: '기능 설정 변경 결과',
noOptions: '변경할 옵션을 하나 이상 선택해주세요.',
title: '湲곕뒫 ?ㅼ젙 蹂€寃?寃곌낵',
noOptions: '蹂€寃쏀븷 ?듭뀡???섎굹 ?댁긽 ?좏깮?댁<?몄슂.',
mimic: {
label: '미믹(Mimic)',
enabled: '활성',
disabled: '비활성',
label: '誘몃?(Mimic)',
enabled: '활성',
disabled: '鍮꾪솢?깊솕',
},
emoji: {
label: '이모지 확대(Big Emoji)',
enabled: '활성',
disabled: '비활성',
label: '?대え吏€ ?뺣?(Big Emoji)',
enabled: '활성',
disabled: '鍮꾪솢?깊솕',
},
},
},
// ── 모달 ────────────────────────────────────────────────
// ?€?€ 紐⑤떖 ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
modals: {
renameTitle: '음성 채널 이름 변경',
renameLabel: '새 채널 이름',
limitTitle: '인원 제한 설정',
limitLabel: '인원 제한 (0 = 무제한, 1-99)',
renameLabel: '??梨꾨꼸 ?대쫫',
limitTitle: '?몄썝 ?쒗븳 ?ㅼ젙',
limitLabel: '?몄썝 ?쒗븳 (0 = 臾댁젣?? 1-99)',
},
// ── 셀렉트 메뉴 플레이스홀더 ────────────────────────────
// ?€?€ ?€?됲듃 硫붾돱 ?뚮젅?댁뒪?€???€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
selects: {
kickUser: '추방할 유저를 선택하세요',
banUser: '차단할 유저를 선택하세요',
transferOwner: '소유권을 이전할 유저를 선택하세요',
},
// ── 상태 메시지 ──────────────────────────────────────────
// ?€?€ ?곹깭 硫붿떆吏€ ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€
presence: {
servers: '{{guildCount}}개의 서버에서 작동 중',
help: '/help 명령어를 확인하세요',
@ -450,3 +410,6 @@ export const ko: TranslationSchema = {
version: 'Kord v1.0.0',
},
};

View File

@ -145,46 +145,6 @@ export interface TranslationSchema {
status: string;
};
};
autorole: {
description: string;
statusTitle: string;
userRoleLabel: string;
botRoleLabel: string;
statusLabel: string;
botStatusLabel: string;
notSet: string;
enabled: string;
disabled: string;
updateSuccess: string;
retroactiveBtn: string;
retroactiveConfirm: string;
retroactiveStarted: string;
excludeTitle: string;
excludeDesc: string;
excludeAddBtn: string;
permissionsError: string;
suspendNotice: string;
};
invite: {
description: string;
listDescription: string;
linkDescription: string;
createDescription: string;
unlinkDescription: string;
codeOption: string;
roleOption: string;
usesOption: string;
ageOption: string;
filterOption: string;
listTitle: string;
listEmpty: string;
linkSuccess: string;
unlinkSuccess: string;
createSuccess: string;
expireWarning: string;
identifyFail: string;
identifyFailDesc: string;
};
music: {
description: string;
addDescription: string;

View File

@ -1,163 +0,0 @@
import { Guild, GuildMember, PermissionFlagsBits } from 'discord.js';
import { prisma } from '../database';
import { logger } from '../utils/logger';
import { auditLogService } from './AuditLogService';
export class AutoRoleService {
/**
* .
*/
async getConfig(guildId: string) {
return prisma.autoRoleConfig.findUnique({
where: { guildId },
});
}
/**
* .
*/
async updateConfig(guildId: string, data: {
userRoleId?: string | null;
botRoleId?: string | null;
isEnabled?: boolean;
botEnabled?: boolean;
}) {
return prisma.autoRoleConfig.upsert({
where: { guildId },
create: {
guildId,
...data,
},
update: data,
});
}
/**
* /.
*/
async setEnabled(guildId: string, enabled: boolean) {
return this.updateConfig(guildId, { isEnabled: enabled });
}
/**
* .
*/
async addExclude(guildId: string, targetId: string, type: 'ROLE' | 'USER') {
return prisma.autoRoleExclude.upsert({
where: {
guildId_targetId_type: {
guildId,
targetId,
type,
},
},
create: {
guildId,
targetId,
type,
},
update: {},
});
}
/**
* .
*/
async removeExclude(guildId: string, targetId: string, type: 'ROLE' | 'USER') {
return prisma.autoRoleExclude.deleteMany({
where: {
guildId,
targetId,
type,
},
});
}
/**
* .
*/
async getExcludes(guildId: string) {
return prisma.autoRoleExclude.findMany({
where: { guildId },
});
}
/**
* .
*/
async isExcluded(member: GuildMember): Promise<boolean> {
const excludes = await this.getExcludes(member.guild.id);
// 유저 ID 체크
if (excludes.some(e => e.type === 'USER' && e.targetId === member.id)) {
return true;
}
// 역할 ID 체크
if (excludes.some(e => e.type === 'ROLE' && member.roles.cache.has(e.targetId))) {
return true;
}
return false;
}
/**
* ( ).
*/
async applyRetroactively(guild: Guild, roleId: string, initiatorId: string) {
const role = guild.roles.cache.get(roleId);
if (!role) return;
// 봇의 권한 및 순위 확인
const botMember = guild.members.me;
if (!botMember || !botMember.permissions.has(PermissionFlagsBits.ManageRoles) || botMember.roles.highest.position <= role.position) {
logger.warn(`AutoRole: Cannot apply role ${role.name} in guild ${guild.id} due to hierarchy/permissions.`);
return;
}
// 모든 멤버 페치 (캐시되지 않았을 수 있음)
const members = await guild.members.fetch();
const targets = members.filter(m => !m.user.bot && !m.roles.cache.has(roleId));
logger.info(`AutoRole: Starting retroactive application of role ${role.name} to ${targets.size} members in guild ${guild.id}`);
let successCount = 0;
let failCount = 0;
// 비동기 실행 (응답 대기 안 함)
(async () => {
for (const [, member] of targets) {
try {
if (await this.isExcluded(member)) continue;
await member.roles.add(role);
successCount++;
// Rate Limit 방지를 위한 지연 (1.5초)
await new Promise(resolve => setTimeout(resolve, 1500));
} catch (error: any) {
if (error.code === 10007 || error.code === 10013) {
// Unknown Member / User (이미 나감)
continue;
}
if (error.code === 50013) {
// Missing Permissions (도중 권한 상실)
logger.error(`AutoRole: Permission lost during retroactive assignment in guild ${guild.id}`);
break;
}
failCount++;
logger.error(`AutoRole: Failed to assign role to ${member.user.tag}:`, error);
}
}
await auditLogService.log(guild, {
category: 'SYSTEM',
severity: 'INFO',
title: 'Retroactive Role Assignment Completed',
description: `Retroactive assignment of <@&${roleId}> role by <@${initiatorId}> has finished.\n- Success: ${successCount}\n- Failed: ${failCount}`,
});
})();
}
}
export const autoRoleService = new AutoRoleService();

View File

@ -1,18 +1,14 @@
import { Client, Guild, Invite, GuildMember, PermissionFlagsBits, Collection } from 'discord.js';
import { Client, Guild, Invite, GuildMember } from 'discord.js';
import { cache } from '../cache';
import { prisma } from '../database';
import { logger } from '../utils/logger';
import { auditLogService } from './AuditLogService';
import { autoRoleService } from './AutoRoleService';
export class InviteService {
private static processingQueue = new Map<string, Promise<void>>();
public static async cacheAllInvites(client: Client) {
for (const [, guild] of client.guilds.cache) {
await this.cacheGuildInvites(guild);
}
logger.info('InviteService: Finished caching all invites.');
logger.info('InviteMaster: Finished caching all invites.');
}
public static async cacheGuildInvites(guild: Guild) {
@ -24,151 +20,69 @@ export class InviteService {
}));
await cache.set(`invites:${guild.id}`, JSON.stringify(inviteData));
} catch (error) {
logger.error(`InviteService: Failed to cache invites for guild ${guild.id}:`, 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(`InviteService: New invite created: ${invite.code}`);
logger.debug(`InviteMaster: New invite created: ${invite.code}`);
await this.cacheGuildInvites(invite.guild as Guild);
}
public static async handleInviteDelete(invite: Invite) {
if (!invite.guild || !(invite.guild instanceof Guild)) return;
logger.debug(`InviteService: Invite deleted: ${invite.code}`);
// 초대 코드와 연결된 역할 매핑이 있는지 확인
const mapping = await prisma.inviteRole.findUnique({
where: {
guildId_inviteCode: {
guildId: invite.guild.id,
inviteCode: invite.code
}
}
});
if (mapping) {
// 매핑 삭제 및 감사 로그 기록
await prisma.inviteRole.delete({ where: { id: mapping.id } });
await auditLogService.log(invite.guild, {
category: 'INVITE',
severity: 'WARN',
title: 'Invite Mapping Removed',
description: `Invite code \`${invite.code}\` was deleted or expired. The mapping to <@&${mapping.roleId}> has been removed.`,
});
}
await this.cacheGuildInvites(invite.guild);
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;
const queueKey = `join:${guild.id}`;
// 동일 서버의 입장 이벤트를 순차적으로 처리하기 위한 큐
const previousTask = this.processingQueue.get(queueKey) || Promise.resolve();
const newTask = previousTask.then(async () => {
try {
await this.processMemberJoin(member);
} catch (error) {
logger.error(`InviteService: Error processing join for ${member.user.tag}:`, error);
}
});
this.processingQueue.set(queueKey, newTask);
// 처리가 끝나면 큐에서 제거 (메모리 누수 방지)
newTask.finally(() => {
if (this.processingQueue.get(queueKey) === newTask) {
this.processingQueue.delete(queueKey);
}
});
}
private static async processMemberJoin(member: GuildMember) {
const guild = member.guild;
const rolesToGive: string[] = [];
// 1. 기본 오토롤 확인
const autoRoleConfig = await autoRoleService.getConfig(guild.id);
if (autoRoleConfig?.isEnabled) {
const roleId = member.user.bot ? autoRoleConfig.botRoleId : autoRoleConfig.userRoleId;
if (roleId && (member.user.bot ? autoRoleConfig.botEnabled : true)) {
rolesToGive.push(roleId);
}
}
// 2. 초대 코드 식별
let usedInviteCode: string | undefined;
try {
const currentInvites = await guild.invites.fetch();
// Fetch current active invites
const newInvites = await guild.invites.fetch();
const cachedData = await cache.get(`invites:${guild.id}`);
let usedInvite: Invite | undefined;
if (cachedData) {
const cachedInvites: { code: string, uses: number }[] = JSON.parse(cachedData);
const usedInvite = currentInvites.find(inv => {
// 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;
});
if (usedInvite) {
usedInviteCode = usedInvite.code;
// 즉시 캐시 업데이트
await this.cacheGuildInvites(guild);
}
}
} catch (error) {
logger.error(`InviteService: Failed to fetch invites for tracking in ${guild.id}:`, error);
}
// 3. 초대 연동 역할 확인
if (usedInviteCode) {
const inviteRole = await prisma.inviteRole.findUnique({
where: {
guildId_inviteCode: {
// 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: usedInviteCode
inviteCode: usedInvite.code
}
}
});
if (inviteRole && !rolesToGive.includes(inviteRole.roleId)) {
rolesToGive.push(inviteRole.roleId);
}
}
// 4. 역할 일괄 부여
if (rolesToGive.length > 0) {
try {
const botMember = guild.members.me;
if (!botMember || !botMember.permissions.has(PermissionFlagsBits.ManageRoles)) {
throw new Error('Missing ManageRoles permission');
}
// 유효한 역할만 필터링하고 계층 확인
const validRoles = rolesToGive.filter(id => {
const role = guild.roles.cache.get(id);
return role && role.position < botMember.roles.highest.position;
});
if (validRoles.length < rolesToGive.length) {
logger.warn(`InviteService: Some roles could not be assigned in ${guild.id} due to hierarchy.`);
await auditLogService.log(guild, {
category: 'PERMISSION',
severity: 'ERROR',
title: 'Role Assignment Failed',
description: `Could not assign some roles to ${member.user.tag} due to hierarchy limits.`,
});
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.`);
}
}
if (validRoles.length > 0) {
await member.roles.add(validRoles);
logger.info(`InviteService: Assigned ${validRoles.length} roles to ${member.user.tag}`);
}
} catch (error) {
logger.error(`InviteService: Failed to assign roles to ${member.user.tag}:`, error);
} 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);
}
}
}

11038
yarn.lock

File diff suppressed because it is too large Load Diff