feat(autorole): implement multi-role UX, persistence and automatic toggle logic

This commit is contained in:
이정수 2026-04-06 18:05:33 +09:00
parent c71d263607
commit f3f882cd06
9 changed files with 120 additions and 114 deletions

View File

@ -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[];

View File

@ -210,8 +210,8 @@ model FeverState {
model AutoRoleConfig { model AutoRoleConfig {
guildId String @id guildId String @id
userRoleId String? userRoleIds String[] @default([])
botRoleId String? botRoleIds String[] @default([])
isEnabled Boolean @default(false) isEnabled Boolean @default(false)
botEnabled Boolean @default(false) botEnabled Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@ -29,75 +29,59 @@ class AutoRoleCommand extends Command {
} }
protected override async handle(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { protected override async handle(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
await interaction.deferReply({ ephemeral: true });
const guild = interaction.guild!; const guild = interaction.guild!;
const config = await autoRoleService.getConfig(guild.id); const dashboard = await generateAutoRoleDashboard(guild, locale);
await interaction.editReply({
const embed = new EmbedBuilder() ...dashboard
.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 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<RoleSelectMenuBuilder>().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<RoleSelectMenuBuilder>().addComponents(botSelect);
const rowRetroactive = new ActionRowBuilder<ButtonBuilder>().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(); export default new AutoRoleCommand().toModule();

View File

@ -151,32 +151,14 @@ export default {
await interaction.deferUpdate(); await interaction.deferUpdate();
const guild = interaction.guild!; const guild = interaction.guild!;
if (interaction.customId === 'autorole_toggle') { if (interaction.customId === 'autorole_retroactive') {
const config = await autoRoleService.getConfig(guild.id); const config = await autoRoleService.getConfig(guild.id);
const newEnabled = !config?.isEnabled; if (config?.userRoleIds && config.userRoleIds.length > 0) {
await autoRoleService.setEnabled(guild.id, newEnabled); await autoRoleService.applyRetroactively(guild, config.userRoleIds, interaction.user.id);
await interaction.editReply({ content: t(locale, 'commands.autorole.updateSuccess'), embeds: [], components: [] }); const { generateAutoRoleDashboard } = require('../commands/autorole');
} else if (interaction.customId === 'autorole_toggle_bot') { const dashboard = await generateAutoRoleDashboard(guild, locale);
const config = await autoRoleService.getConfig(guild.id); await interaction.editReply({ content: t(locale, 'commands.autorole.retroactiveStarted'), ...dashboard });
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); }, locale);
@ -190,16 +172,18 @@ export default {
const guild = interaction.guild!; const guild = interaction.guild!;
const isBot = interaction.customId.includes('bot'); const isBot = interaction.customId.includes('bot');
const roleId = interaction.values[0]; const roleIds = interaction.values;
await autoRoleService.updateConfig(guild.id, { 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({ await interaction.editReply({
content: t(locale, 'commands.autorole.updateSuccess'), content: '',
embeds: [], ...dashboard
components: []
}); });
}, locale); }, locale);
} }

View File

@ -198,6 +198,12 @@ export const en: TranslationSchema = {
botRoleLabel: 'Bot Role', botRoleLabel: 'Bot Role',
statusLabel: 'User Auto Assignment', statusLabel: 'User Auto Assignment',
botStatusLabel: 'Bot 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', notSet: 'Not Set',
enabled: 'Enabled', enabled: 'Enabled',
disabled: 'Disabled', disabled: 'Disabled',

View File

@ -198,6 +198,12 @@ export const ko: TranslationSchema = {
botRoleLabel: '봇 역할', botRoleLabel: '봇 역할',
statusLabel: '유저 자동 부여', statusLabel: '유저 자동 부여',
botStatusLabel: '봇 자동 부여', botStatusLabel: '봇 자동 부여',
userRolePlaceholder: '유저 기본 역할을 선택하세요',
botRolePlaceholder: '봇 기본 역할을 선택하세요',
toggleUserEnable: '🟢 유저 자동부여 켜기',
toggleUserDisable: '🔴 유저 자동부여 끄기',
toggleBotEnable: '🟢 봇 자동부여 켜기',
toggleBotDisable: '🔴 봇 자동부여 끄기',
notSet: '미설정', notSet: '미설정',
enabled: '활성', enabled: '활성',
disabled: '비활성', disabled: '비활성',

View File

@ -152,6 +152,12 @@ export interface TranslationSchema {
botRoleLabel: string; botRoleLabel: string;
statusLabel: string; statusLabel: string;
botStatusLabel: string; botStatusLabel: string;
userRolePlaceholder: string;
botRolePlaceholder: string;
toggleUserEnable: string;
toggleUserDisable: string;
toggleBotEnable: string;
toggleBotDisable: string;
notSet: string; notSet: string;
enabled: string; enabled: string;
disabled: string; disabled: string;

View File

@ -17,8 +17,8 @@ export class AutoRoleService {
* . * .
*/ */
async updateConfig(guildId: string, data: { async updateConfig(guildId: string, data: {
userRoleId?: string | null; userRoleIds?: string[];
botRoleId?: string | null; botRoleIds?: string[];
isEnabled?: boolean; isEnabled?: boolean;
botEnabled?: boolean; botEnabled?: boolean;
}) { }) {
@ -89,12 +89,12 @@ export class AutoRoleService {
const excludes = await this.getExcludes(member.guild.id); const excludes = await this.getExcludes(member.guild.id);
// 유저 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; return true;
} }
// 역할 ID 체크 // 역할 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; return true;
} }
@ -104,22 +104,30 @@ export class AutoRoleService {
/** /**
* ( ). * ( ).
*/ */
async applyRetroactively(guild: Guild, roleId: string, initiatorId: string) { async applyRetroactively(guild: Guild, roleIds: string[], initiatorId: string) {
const role = guild.roles.cache.get(roleId); if (!roleIds || roleIds.length === 0) return;
if (!role) 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; const botMember = guild.members.me;
if (!botMember || !botMember.permissions.has(PermissionFlagsBits.ManageRoles) || botMember.roles.highest.position <= role.position) { if (!botMember || !botMember.permissions.has(PermissionFlagsBits.ManageRoles)) {
logger.warn(`AutoRole: Cannot apply role ${role.name} in guild ${guild.id} due to hierarchy/permissions.`); 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; return;
} }
// 모든 멤버 페치 (캐시되지 않았을 수 있음) // 모든 멤버 페치 (캐시되지 않았을 수 있음)
const members = await guild.members.fetch(); 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 successCount = 0;
let failCount = 0; let failCount = 0;
@ -130,7 +138,7 @@ export class AutoRoleService {
try { try {
if (await this.isExcluded(member)) continue; if (await this.isExcluded(member)) continue;
await member.roles.add(role); await member.roles.add(validRoles);
successCount++; successCount++;
// Rate Limit 방지를 위한 지연 (1.5초) // Rate Limit 방지를 위한 지연 (1.5초)
@ -154,7 +162,7 @@ export class AutoRoleService {
category: 'SYSTEM', category: 'SYSTEM',
severity: 'INFO', severity: 'INFO',
title: 'Retroactive Role Assignment Completed', 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}`,
}); });
})(); })();
} }

View File

@ -92,10 +92,10 @@ export class InviteService {
// 1. 기본 오토롤 확인 // 1. 기본 오토롤 확인
const autoRoleConfig = await autoRoleService.getConfig(guild.id); const autoRoleConfig = await autoRoleService.getConfig(guild.id);
if (autoRoleConfig?.isEnabled) { if (autoRoleConfig) {
const roleId = member.user.bot ? autoRoleConfig.botRoleId : autoRoleConfig.userRoleId; const roleIds = member.user.bot ? autoRoleConfig.botRoleIds : autoRoleConfig.userRoleIds;
if (roleId && (member.user.bot ? autoRoleConfig.botEnabled : true)) { if (roleIds && roleIds.length > 0) {
rolesToGive.push(roleId); rolesToGive.push(...roleIds);
} }
} }