From f3f882cd06ebdba88162d0cb17199ad9d6e4e5f2 Mon Sep 17 00:00:00 2001 From: artbiit Date: Mon, 6 Apr 2026 18:05:33 +0900 Subject: [PATCH] feat(autorole): implement multi-role UX, persistence and automatic toggle logic --- .../migration.sql | 12 ++ prisma/schema.prisma | 4 +- src/commands/autorole.ts | 116 ++++++++---------- src/events/interactionCreate.ts | 42 ++----- src/i18n/locales/en.ts | 6 + src/i18n/locales/ko.ts | 6 + src/i18n/types.ts | 6 + src/services/AutoRoleService.ts | 34 +++-- src/services/InviteService.ts | 8 +- 9 files changed, 120 insertions(+), 114 deletions(-) create mode 100644 prisma/migrations/20260406083720_autorole_multiple_roles/migration.sql 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/schema.prisma b/prisma/schema.prisma index 5dc9f51..9808ab3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -210,8 +210,8 @@ model FeverState { model AutoRoleConfig { guildId String @id - userRoleId String? - botRoleId String? + userRoleIds String[] @default([]) + botRoleIds String[] @default([]) isEnabled Boolean @default(false) botEnabled Boolean @default(false) createdAt DateTime @default(now()) diff --git a/src/commands/autorole.ts b/src/commands/autorole.ts index 488e50f..f1006a9 100644 --- a/src/commands/autorole.ts +++ b/src/commands/autorole.ts @@ -29,75 +29,59 @@ class AutoRoleCommand extends Command { } protected override async handle(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { + await interaction.deferReply({ ephemeral: true }); 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().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().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().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, + 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); + + const rowRetroactive = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('autorole_retroactive') + .setLabel(t(locale, 'commands.autorole.retroactiveBtn')) + .setStyle(ButtonStyle.Secondary) + .setDisabled(!config?.userRoleIds || config.userRoleIds.length === 0), + new ButtonBuilder() + .setCustomId('autorole_exclude') + .setLabel(t(locale, 'commands.autorole.excludeTitle')) + .setStyle(ButtonStyle.Secondary) + ); + + return { + embeds: [embed], + components: [rowUserRole, rowBotRole, rowRetroactive] + }; +} + export default new AutoRoleCommand().toModule(); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index af9a8ff..680b764 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -151,32 +151,14 @@ export default { await interaction.deferUpdate(); const guild = interaction.guild!; - if (interaction.customId === 'autorole_toggle') { + if (interaction.customId === 'autorole_retroactive') { 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: [] }); + if (config?.userRoleIds && config.userRoleIds.length > 0) { + await autoRoleService.applyRetroactively(guild, config.userRoleIds, interaction.user.id); + const { generateAutoRoleDashboard } = require('../commands/autorole'); + const dashboard = await generateAutoRoleDashboard(guild, locale); + await interaction.editReply({ content: t(locale, 'commands.autorole.retroactiveStarted'), ...dashboard }); } - } 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().addComponents(roleSelect); - await interaction.editReply({ components: [selectRow] }); } // 나머지 버튼 처리 }, locale); @@ -190,16 +172,18 @@ export default { const guild = interaction.guild!; const isBot = interaction.customId.includes('bot'); - const roleId = interaction.values[0]; + const roleIds = interaction.values; await autoRoleService.updateConfig(guild.id, { - [isBot ? 'botRoleId' : 'userRoleId']: roleId + [isBot ? 'botRoleIds' : 'userRoleIds']: roleIds }); + const { generateAutoRoleDashboard } = require('../commands/autorole'); + const dashboard = await generateAutoRoleDashboard(guild, locale); + await interaction.editReply({ - content: t(locale, 'commands.autorole.updateSuccess'), - embeds: [], - components: [] + content: '', + ...dashboard }); }, locale); } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a0d4ec0..6391d59 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -198,6 +198,12 @@ export const en: TranslationSchema = { 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', diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 55dc739..cc1abd9 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -198,6 +198,12 @@ export const ko: TranslationSchema = { botRoleLabel: '봇 역할', statusLabel: '유저 자동 부여', botStatusLabel: '봇 자동 부여', + userRolePlaceholder: '유저 기본 역할을 선택하세요', + botRolePlaceholder: '봇 기본 역할을 선택하세요', + toggleUserEnable: '🟢 유저 자동부여 켜기', + toggleUserDisable: '🔴 유저 자동부여 끄기', + toggleBotEnable: '🟢 봇 자동부여 켜기', + toggleBotDisable: '🔴 봇 자동부여 끄기', notSet: '미설정', enabled: '활성', disabled: '비활성', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index df19e34..5c55b98 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -152,6 +152,12 @@ export interface TranslationSchema { botRoleLabel: string; statusLabel: string; botStatusLabel: string; + userRolePlaceholder: string; + botRolePlaceholder: string; + toggleUserEnable: string; + toggleUserDisable: string; + toggleBotEnable: string; + toggleBotDisable: string; notSet: string; enabled: string; disabled: string; diff --git a/src/services/AutoRoleService.ts b/src/services/AutoRoleService.ts index deec8de..e124be6 100644 --- a/src/services/AutoRoleService.ts +++ b/src/services/AutoRoleService.ts @@ -17,8 +17,8 @@ export class AutoRoleService { * 서버의 자동 역할 설정을 업데이트합니다. */ async updateConfig(guildId: string, data: { - userRoleId?: string | null; - botRoleId?: string | null; + userRoleIds?: string[]; + botRoleIds?: string[]; isEnabled?: boolean; botEnabled?: boolean; }) { @@ -89,12 +89,12 @@ export class AutoRoleService { const excludes = await this.getExcludes(member.guild.id); // 유저 ID 체크 - if (excludes.some(e => e.type === 'USER' && e.targetId === member.id)) { + if (excludes.some((e: any) => e.type === 'USER' && e.targetId === member.id)) { return true; } // 역할 ID 체크 - if (excludes.some(e => e.type === 'ROLE' && member.roles.cache.has(e.targetId))) { + if (excludes.some((e: any) => e.type === 'ROLE' && member.roles.cache.has(e.targetId))) { return true; } @@ -104,22 +104,30 @@ export class AutoRoleService { /** * 서버 전체에 역할을 소급 적용합니다 (백그라운드 처리). */ - async applyRetroactively(guild: Guild, roleId: string, initiatorId: string) { - const role = guild.roles.cache.get(roleId); - if (!role) return; + async applyRetroactively(guild: Guild, roleIds: string[], initiatorId: string) { + if (!roleIds || roleIds.length === 0) return; + + const roles = roleIds.map(id => guild.roles.cache.get(id)).filter((r): r is import('discord.js').Role => r !== undefined); + if (roles.length === 0) 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.`); + if (!botMember || !botMember.permissions.has(PermissionFlagsBits.ManageRoles)) { + logger.warn(`AutoRole: Cannot apply roles in guild ${guild.id} due to missing ManageRoles permission.`); + return; + } + + const validRoles = roles.filter(r => botMember.roles.highest.position > r.position); + if (validRoles.length === 0) { + logger.warn(`AutoRole: Cannot apply roles in guild ${guild.id} due to hierarchy limitations.`); return; } // 모든 멤버 페치 (캐시되지 않았을 수 있음) const members = await guild.members.fetch(); - const targets = members.filter(m => !m.user.bot && !m.roles.cache.has(roleId)); + const targets = members.filter(m => !m.user.bot && validRoles.some(r => !m.roles.cache.has(r.id))); - logger.info(`AutoRole: Starting retroactive application of role ${role.name} to ${targets.size} members in guild ${guild.id}`); + logger.info(`AutoRole: Starting retroactive application of ${validRoles.length} roles to ${targets.size} members in guild ${guild.id}`); let successCount = 0; let failCount = 0; @@ -130,7 +138,7 @@ export class AutoRoleService { try { if (await this.isExcluded(member)) continue; - await member.roles.add(role); + await member.roles.add(validRoles); successCount++; // Rate Limit 방지를 위한 지연 (1.5초) @@ -154,7 +162,7 @@ export class AutoRoleService { 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}`, + description: `Retroactive assignment of roles (${validRoles.map(r => `<@&${r.id}>`).join(', ')}) by <@${initiatorId}> has finished.\n- Success: ${successCount}\n- Failed: ${failCount}`, }); })(); } diff --git a/src/services/InviteService.ts b/src/services/InviteService.ts index 18da4e2..502c309 100644 --- a/src/services/InviteService.ts +++ b/src/services/InviteService.ts @@ -92,10 +92,10 @@ export class InviteService { // 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); + if (autoRoleConfig) { + const roleIds = member.user.bot ? autoRoleConfig.botRoleIds : autoRoleConfig.userRoleIds; + if (roleIds && roleIds.length > 0) { + rolesToGive.push(...roleIds); } }