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 {
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())

View File

@ -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 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)
.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
.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 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 rowUserRole = new ActionRowBuilder<RoleSelectMenuBuilder>().addComponents(userSelect);
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 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 row3 = new ActionRowBuilder<ButtonBuilder>().addComponents(
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?.userRoleId),
.setDisabled(!config?.userRoleIds || config.userRoleIds.length === 0),
new ButtonBuilder()
.setCustomId('autorole_exclude')
.setLabel(t(locale, 'commands.autorole.excludeTitle'))
.setStyle(ButtonStyle.Secondary)
);
await interaction.reply({
return {
embeds: [embed],
components: [row1, row2, row3],
ephemeral: true,
});
}
components: [rowUserRole, rowBotRole, rowRetroactive]
};
}
export default new AutoRoleCommand().toModule();

View File

@ -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<any>().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);
}

View File

@ -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',

View File

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

View File

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

View File

@ -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}`,
});
})();
}

View File

@ -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);
}
}