diff --git a/.agents/rules/discord_ui_ux.md b/.agents/rules/discord_ui_ux.md new file mode 100644 index 0000000..fa9a346 --- /dev/null +++ b/.agents/rules/discord_ui_ux.md @@ -0,0 +1,27 @@ +--- +description: Discord Bot UI/UX Design Philosophy +--- + +# Discord Bot UI/UX Design Philosophy + +When designing or updating Discord command interfaces (Embeds, Components), adhere to the following UI/UX philosophy to ensure a clean, intuitive, and modern user experience. + +## 1. Minimal and Non-redundant Information (중복 정보 최소화) +- Do not display information in the Embed that is already visually apparent in the UI components. +- For example, if a `RoleSelectMenuBuilder` allows the user to select roles, use `.addDefaultRoles(ids)` (available in discord.js 14.14+) to display the currently selected roles natively inside the dropdown menu. +- Do NOT list those same roles redundantly as text inside the Embed fields. The Embed should remain concise, showing only titles and essential descriptions or instructions. + +## 2. Implicit State (명시적 토글 지양 및 상태 직관화) +- Avoid creating manual On/Off toggle buttons unless absolutely necessary. +- Derive the "Enabled/Disabled" state directly from the user's data naturally. For instance, if the user has selected at least one role (`roleIds.length > 0`), the feature is automatically considered "Active/Enabled". If they clear the selection, the feature is "Disabled". +- This reduces UI clutter (removing unnecessary toggle ActionRows) and aligns with modern design patterns where state implicitly follows the presence of data. + +## 3. Persistent and Seamless Interaction (매끄러운 대시보드 유지) +- Component interactions should feel fast and seamless without fragmenting the chat history. +- Always immediately call `await interaction.deferUpdate();` (or equivalent) when handling components (buttons, select menus) to prevent "Unknown interaction" timeout errors. +- Use `await interaction.editReply(...)` with the newly generated UI components to seamlessly update the dashboard frame in place. +- Do NOT generate new follow-up messages or close the menu unilaterally when the user still expects to tweak settings. + +## 4. Safe Response Timings (타임아웃 방지) +- When processing `ChatInputCommandInteraction` that might involve a database cold-start connection or external API calls, proactively call `await interaction.deferReply({ ephemeral: true });` right at the start. +- Update the UI with `await interaction.editReply(...)` once business logic resolves, bypassing Discord's strict 3-second timeout limitation and preventing crashes during initial boot load. diff --git a/prisma/migrations/20260406060242_add_autorole_config/migration.sql b/prisma/migrations/20260406060242_add_autorole_config/migration.sql new file mode 100644 index 0000000..0d18e50 --- /dev/null +++ b/prisma/migrations/20260406060242_add_autorole_config/migration.sql @@ -0,0 +1,29 @@ +-- 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"); diff --git a/prisma/migrations/20260406083720_autorole_multiple_roles/migration.sql b/prisma/migrations/20260406083720_autorole_multiple_roles/migration.sql new file mode 100644 index 0000000..34a3718 --- /dev/null +++ b/prisma/migrations/20260406083720_autorole_multiple_roles/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `botRoleId` on the `AutoRoleConfig` table. All the data in the column will be lost. + - You are about to drop the column `userRoleId` on the `AutoRoleConfig` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "AutoRoleConfig" DROP COLUMN "botRoleId", +DROP COLUMN "userRoleId", +ADD COLUMN "botRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN "userRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/migrations/20260407023407_remove_autorole_exclude/migration.sql b/prisma/migrations/20260407023407_remove_autorole_exclude/migration.sql new file mode 100644 index 0000000..e3e5abd --- /dev/null +++ b/prisma/migrations/20260407023407_remove_autorole_exclude/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the `AutoRoleExclude` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +DROP TABLE "AutoRoleExclude"; + +-- DropEnum +DROP TYPE "ExcludeType"; diff --git a/prisma/migrations/20260407073319_remove_invite_role_model/migration.sql b/prisma/migrations/20260407073319_remove_invite_role_model/migration.sql new file mode 100644 index 0000000..d5bad3a --- /dev/null +++ b/prisma/migrations/20260407073319_remove_invite_role_model/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the `InviteRole` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +DROP TABLE "InviteRole"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8fa3773..7c2a62d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,16 +17,6 @@ model GuildConfig { updatedAt DateTime @updatedAt } -model InviteRole { - id String @id @default(uuid()) - guildId String - inviteCode String - roleId String - createdAt DateTime @default(now()) - - @@unique([guildId, inviteCode]) -} - model UserSubscription { userId String @id tier SubscriptionTier @default(FREE) @@ -208,6 +198,18 @@ model FeverState { updatedAt DateTime @updatedAt } +model AutoRoleConfig { + guildId String @id + userRoleIds String[] @default([]) + botRoleIds String[] @default([]) + isEnabled Boolean @default(false) + botEnabled Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + + + model RefinementLevelConfig { level Int @id successRate Float diff --git a/src/client/KordClient.ts b/src/client/KordClient.ts index 135eeab..02cf2c3 100644 --- a/src/client/KordClient.ts +++ b/src/client/KordClient.ts @@ -16,7 +16,7 @@ export class KordClient extends Client { GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, - GatewayIntentBits.GuildInvites, + GatewayIntentBits.GuildMembers, ], partials: [Partials.Message, Partials.Channel, Partials.GuildMember], }); diff --git a/src/commands/autorole.ts b/src/commands/autorole.ts new file mode 100644 index 0000000..a987073 --- /dev/null +++ b/src/commands/autorole.ts @@ -0,0 +1,75 @@ +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) { + await interaction.deferReply({ ephemeral: true }); + const guild = interaction.guild!; + const dashboard = await generateAutoRoleDashboard(guild, locale); + await interaction.editReply({ + ...dashboard + }); + } +} + +export async function generateAutoRoleDashboard(guild: import('discord.js').Guild, locale: SupportedLocale) { + const config = await autoRoleService.getConfig(guild.id); + + const embed = new EmbedBuilder() + .setTitle(t(locale, 'commands.autorole.statusTitle')) + .setColor(Colors.Blue) + .setDescription(t(locale, 'commands.autorole.description') || '유저 및 봇이 서버에 접속할 때 자동으로 부여할 기본 역할을 선택하세요. 역할을 선택하면 즉시 활성화됩니다.'); + + const userSelect = new RoleSelectMenuBuilder() + .setCustomId('autorole_select_user') + .setPlaceholder(t(locale, 'commands.autorole.userRolePlaceholder')) + .setMaxValues(10); + if (config?.userRoleIds && config.userRoleIds.length > 0) { + userSelect.addDefaultRoles(config.userRoleIds); + } + + const rowUserRole = new ActionRowBuilder().addComponents(userSelect); + + const botSelect = new RoleSelectMenuBuilder() + .setCustomId('autorole_select_bot') + .setPlaceholder(t(locale, 'commands.autorole.botRolePlaceholder')) + .setMaxValues(10); + if (config?.botRoleIds && config.botRoleIds.length > 0) { + botSelect.addDefaultRoles(config.botRoleIds); + } + + const rowBotRole = new ActionRowBuilder().addComponents(botSelect); + + return { + embeds: [embed], + components: [rowUserRole, rowBotRole] + }; +} + +export default new AutoRoleCommand().toModule(); diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index 45af01c..b95a92f 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -1,12 +1,10 @@ import { Events, Guild } from 'discord.js'; -import { InviteService } from '../services/InviteService'; import { PresenceService } from '../services/PresenceService'; export default { name: Events.GuildCreate, once: false, async execute(guild: Guild) { - await InviteService.cacheGuildInvites(guild); PresenceService.updatePresence(guild.client); }, }; diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index dec0bd4..e070c88 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,10 +1,10 @@ import { Events, GuildMember } from 'discord.js'; -import { InviteService } from '../services/InviteService'; +import { autoRoleService } from '../services/AutoRoleService'; export default { name: Events.GuildMemberAdd, once: false, async execute(member: GuildMember) { - await InviteService.handleMemberAdd(member); + await autoRoleService.handleMemberJoin(member); }, }; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 663a3a9..4387a64 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -143,6 +143,40 @@ 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(); + + // 나머지 버튼 처리 (현재 사용 안함) + }, 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 roleIds = interaction.values; + + await autoRoleService.updateConfig(guild.id, { + [isBot ? 'botRoleIds' : 'userRoleIds']: roleIds + }); + + const { generateAutoRoleDashboard } = require('../commands/autorole'); + const dashboard = await generateAutoRoleDashboard(guild, locale); + + await interaction.editReply({ + content: '', + ...dashboard + }); + }, locale); + } else if (interaction.isModalSubmit()) { const customId = interaction.customId; if (customId.startsWith('modal_vc_')) { diff --git a/src/events/inviteCreate.ts b/src/events/inviteCreate.ts deleted file mode 100644 index ec2c787..0000000 --- a/src/events/inviteCreate.ts +++ /dev/null @@ -1,10 +0,0 @@ -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); - }, -}; diff --git a/src/events/inviteDelete.ts b/src/events/inviteDelete.ts deleted file mode 100644 index 9e52e5b..0000000 --- a/src/events/inviteDelete.ts +++ /dev/null @@ -1,10 +0,0 @@ -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); - }, -}; diff --git a/src/events/ready.ts b/src/events/ready.ts index 80d4fcd..5f256bc 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,7 +1,6 @@ import { Events } from 'discord.js'; import { KordClient } from '../client/KordClient'; import { logger } from '../utils/logger'; -import { InviteService } from '../services/InviteService'; import { VoiceService } from '../services/VoiceService'; import { PresenceService } from '../services/PresenceService'; import { EventService } from '../services/EventService'; @@ -14,7 +13,6 @@ export default { once: true, async execute(client: KordClient) { logger.info(`Ready! Logged in as ${client.user?.tag}`); - await InviteService.cacheAllInvites(client); await VoiceService.syncChannels(client); PresenceService.startActivePresence(client); EventService.startReminderLoop(client); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1d567fd..13a287a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1,4 +1,4 @@ -import { TranslationSchema } from '../types'; +import { TranslationSchema } from '../types'; /** * English translations ??the DEFAULT and FALLBACK locale. @@ -191,6 +191,26 @@ 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', + userRolePlaceholder: 'Select default user role', + botRolePlaceholder: 'Select default bot role', + toggleUserEnable: '🟢 Enable User AutoRole', + toggleUserDisable: '🔴 Disable User AutoRole', + toggleBotEnable: '🟢 Enable Bot AutoRole', + toggleBotDisable: '🔴 Disable Bot AutoRole', + notSet: 'Not Set', + enabled: 'Enabled', + disabled: 'Disabled', + updateSuccess: 'Auto role settings have been updated.', + 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.', + }, music: { description: 'Play YouTube audio in voice channels.', addDescription: 'Search YouTube or add a video URL to the queue.', @@ -328,8 +348,6 @@ export const en: TranslationSchema = { VOICE_GLOBAL: 'Voice Channels (Global)', VOICE_GENERATOR_CHANNEL: 'Voice Generator Channel', VOICE_GENERATOR_CATEGORY: 'Voice Generator Category', - INVITE_TRACKING: 'Invite Tracking', - INVITE_ROLE_HIERARCHY: 'Invite Role Assignment (Hierarchy)', MIMIC_WEBHOOK: 'Message Mimic (Webhook)', }, }, @@ -388,7 +406,6 @@ export const en: TranslationSchema = { BOOT: 'Boot', VOICE: 'Voice', PERMISSION: 'Permission', - INVITE: 'Invite', }, }, config: { diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index bb79774..055be0f 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -1,196 +1,216 @@ -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: '봇 자동 부여', + userRolePlaceholder: '유저 기본 역할을 선택하세요', + botRolePlaceholder: '봇 기본 역할을 선택하세요', + toggleUserEnable: '🟢 유저 자동부여 켜기', + toggleUserDisable: '🔴 유저 자동부여 끄기', + toggleBotEnable: '🟢 봇 자동부여 켜기', + toggleBotDisable: '🔴 봇 자동부여 끄기', + notSet: '미설정', + enabled: '활성', + disabled: '비활성', + updateSuccess: '자동 역할 설정이 업데이트되었습니다.', + permissionsError: '봇의 역할 순위가 낮거나 권한이 부족하여 역할을 부여할 수 없습니다.', + suspendNotice: '권한 부족으로 인해 자동 역할 부여 기능이 일시 중지되었습니다. 봇의 권한과 역할 순위를 확인해 주세요.', + }, music: { description: 'Play YouTube audio in voice channels.', addDescription: 'Search YouTube or add a video URL to the queue.', @@ -317,112 +337,109 @@ 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: '음성 생성기 카테고리', + 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}}**)', - placeholder: '언어를 선택하세요', - nextBtn: '?ㅼ쓬 ?④퀎', - skipBtn: '嫄대꼫?곌린' + title: '1️⃣ 언어 설정', + desc: '서버 전체에 적용될 봇의 기본 언어를 선택하세요. (현재: **{{locale}}**)', + placeholder: '언어를 선택하세요', + nextBtn: '다음 단계', + skipBtn: '건너뛰기' }, step2: { - title: '2截뤴깵 ?꾩닔 沅뚰븳 ?먭?', - descOk: '??**紐⑤뱺 ?꾩닔 沅뚰븳???뺤긽?곸쑝濡?遺€?щ릺???덉뒿?덈떎.**', - descFail: '?좑툘 **?쇰? 沅뚰븳??遺€議깊빀?덈떎.**\n寃곌낵瑜??뺤씤?섍퀬 遊???븷???꾩슂??湲곕뒫 沅뚰븳??遺€?ы빐二쇱꽭??', - recheckBtn: '다시 검사하기', - nextBtn: '?ㅼ쓬 ?④퀎' + title: '2️⃣ 필수 권한 점검', + descOk: '✅ **모든 필수 권한이 정상적으로 부여되어 있습니다.**', + descFail: '⚠️ **일부 권한이 부족합니다.**\n결과를 확인하고 봇 역할에 필요한 기능 권한을 부여해주세요.', + recheckBtn: '다시 검사하기', + 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: '권한', }, }, 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 명령어를 확인하세요', @@ -430,6 +447,3 @@ export const ko: TranslationSchema = { version: 'Kord v1.0.0', }, }; - - - diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 967a84e..1f130ed 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -145,6 +145,26 @@ export interface TranslationSchema { status: string; }; }; + autorole: { + description: string; + statusTitle: string; + userRoleLabel: string; + botRoleLabel: string; + statusLabel: string; + botStatusLabel: string; + userRolePlaceholder: string; + botRolePlaceholder: string; + toggleUserEnable: string; + toggleUserDisable: string; + toggleBotEnable: string; + toggleBotDisable: string; + notSet: string; + enabled: string; + disabled: string; + updateSuccess: string; + permissionsError: string; + suspendNotice: string; + }; music: { description: string; addDescription: string; @@ -282,8 +302,6 @@ export interface TranslationSchema { VOICE_GLOBAL: string; VOICE_GENERATOR_CHANNEL: string; VOICE_GENERATOR_CATEGORY: string; - INVITE_TRACKING: string; - INVITE_ROLE_HIERARCHY: string; MIMIC_WEBHOOK: string; }; }; @@ -305,7 +323,6 @@ export interface TranslationSchema { BOOT: string; VOICE: string; PERMISSION: string; - INVITE: string; }; }; config: { diff --git a/src/services/AuditLogService.ts b/src/services/AuditLogService.ts index 9352bdd..c18444f 100644 --- a/src/services/AuditLogService.ts +++ b/src/services/AuditLogService.ts @@ -3,7 +3,7 @@ import { prisma } from '../database'; import { env } from '../config/env'; export type AuditSeverity = 'INFO' | 'WARN' | 'ERROR'; -export type AuditCategory = 'SYSTEM' | 'BOOT' | 'VOICE' | 'PERMISSION' | 'INVITE' | 'MIMIC'; +export type AuditCategory = 'SYSTEM' | 'BOOT' | 'VOICE' | 'PERMISSION' | 'MIMIC'; export interface AuditLogPayload { category: AuditCategory; diff --git a/src/services/AutoRoleService.ts b/src/services/AutoRoleService.ts new file mode 100644 index 0000000..f942a6b --- /dev/null +++ b/src/services/AutoRoleService.ts @@ -0,0 +1,72 @@ +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: { + userRoleIds?: string[]; + botRoleIds?: string[]; + 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 handleMemberJoin(member: GuildMember) { + const config = await this.getConfig(member.guild.id); + if (!config) return; + + const isBot = member.user.bot; + const isEnabled = isBot ? config.botEnabled : config.isEnabled; + const roleIds = isBot ? config.botRoleIds : config.userRoleIds; + + if (!isEnabled || roleIds.length === 0) return; + + try { + await member.roles.add(roleIds, 'Kord Auto-Role'); + logger.info(`[AutoRole] Added roles to ${member.user.tag} in ${member.guild.name}`); + } catch (error) { + logger.error(`[AutoRole] Failed to add roles to ${member.user.tag} in ${member.guild.name}`, error); + + // 권한 문제인 경우 감사 로그에 기록 + await auditLogService.log(member.guild, { + category: 'PERMISSION', + severity: 'WARN', + title: 'Auto-Role Failure', + description: `Failed to assign roles to ${member.user.toString()} automatically. Please check the bot's permission and role hierarchy.` + }).catch(() => {}); + } + } +} + +export const autoRoleService = new AutoRoleService(); diff --git a/src/services/InviteService.ts b/src/services/InviteService.ts deleted file mode 100644 index 42fd6c4..0000000 --- a/src/services/InviteService.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Client, Guild, Invite, GuildMember } from 'discord.js'; -import { cache } 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 cache.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 cache.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); - } - } -} diff --git a/src/services/PermissionAuditService.ts b/src/services/PermissionAuditService.ts index 70aed84..694a798 100644 --- a/src/services/PermissionAuditService.ts +++ b/src/services/PermissionAuditService.ts @@ -109,24 +109,7 @@ const FEATURE_DEFINITIONS: FeatureDefinition[] = [ }, }, - // ── 5. 초대 추적 ── - { - featureKey: 'INVITE_TRACKING', - scope: 'guild', - permissions: [PermissionFlagsBits.ManageGuild], - }, - - // ── 6. 역할 자동 부여 (초대 연동) - 계층 검사 ── - { - featureKey: 'INVITE_ROLE_HIERARCHY', - scope: 'hierarchy', - resolveTargetRoleIds: async (guildId) => { - const inviteRoles = await prisma.inviteRole.findMany({ where: { guildId } }); - return inviteRoles.map((ir: { roleId: string }) => ir.roleId); - }, - }, - - // ── 7. 메시지 흉내 (Mimic) ── + // ── 5. 메시지 흉내 (Mimic) ── { featureKey: 'MIMIC_WEBHOOK', scope: 'guild',