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/schema.prisma b/prisma/schema.prisma index f6abb52..5dc9f51 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -208,6 +208,31 @@ 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 diff --git a/src/commands/autorole.ts b/src/commands/autorole.ts new file mode 100644 index 0000000..7e4cd63 --- /dev/null +++ b/src/commands/autorole.ts @@ -0,0 +1,120 @@ +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 + } + ); + + const row1 = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('autorole_toggle') + .setLabel(config?.isEnabled ? t(locale, 'commands.autorole.disabled') : t(locale, 'commands.autorole.enabled')) + .setStyle(config?.isEnabled ? ButtonStyle.Danger : ButtonStyle.Success), + 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 row2 = 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) + ); + + const response = await interaction.reply({ + embeds: [embed], + components: [row1, row2], + ephemeral: true, + }); + + const collector = response.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 300000, // 5분 + }); + + collector.on('collect', async (i) => { + if (i.customId === 'autorole_toggle') { + const newEnabled = !config?.isEnabled; + await autoRoleService.setEnabled(guild.id, newEnabled); + await i.update({ content: t(locale, 'commands.autorole.updateSuccess'), embeds: [], components: [] }); + collector.stop(); + } else if (i.customId === 'autorole_set_user' || i.customId === 'autorole_set_bot') { + const isBot = i.customId === 'autorole_set_bot'; + 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 i.update({ components: [selectRow] }); + } else if (i.customId === 'autorole_retroactive') { + if (!config?.userRoleId) return; + await autoRoleService.applyRetroactively(guild, config.userRoleId, i.user.id); + await i.update({ content: t(locale, 'commands.autorole.retroactiveStarted'), embeds: [], components: [] }); + collector.stop(); + } + // exclude 등 추가 인터랙션 구현 예정 + }); + } +} + +export default new AutoRoleCommand().toModule(); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b5c7c63..a0d4ec0 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,46 @@ 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.', diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 855a598..aaae069 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -1,11 +1,11 @@ -import { TranslationSchema } from '../types'; +import { TranslationSchema } from '../types'; /** - * ?쒓뎅??踰덉뿭 ?뚯씪. - * 紐⑤뱺 ?ㅺ? en.ts?€ 1:1 ?€?묐릺?댁빞 ?⑸땲?? + * 한국어 번역 파일. + * 모든 키는 en.ts와 1:1 대응되어야 합니다. */ export const ko: TranslationSchema = { - // ?€?€ ?먮윭 硫붿떆吏€ ?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€?€ + // 에러 메시지 errors: { E1001: { userMessage: '사용자 제한 값이 올바르지 않습니다.', @@ -184,13 +184,53 @@ export const ko: TranslationSchema = { 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.', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index a18d9ae..df19e34 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -145,6 +145,46 @@ 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; diff --git a/src/services/AutoRoleService.ts b/src/services/AutoRoleService.ts new file mode 100644 index 0000000..deec8de --- /dev/null +++ b/src/services/AutoRoleService.ts @@ -0,0 +1,163 @@ +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 { + 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(); diff --git a/src/services/InviteService.ts b/src/services/InviteService.ts index 42fd6c4..18da4e2 100644 --- a/src/services/InviteService.ts +++ b/src/services/InviteService.ts @@ -1,14 +1,18 @@ -import { Client, Guild, Invite, GuildMember } from 'discord.js'; +import { Client, Guild, Invite, GuildMember, PermissionFlagsBits, Collection } 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>(); + public static async cacheAllInvites(client: Client) { for (const [, guild] of client.guilds.cache) { await this.cacheGuildInvites(guild); } - logger.info('InviteMaster: Finished caching all invites.'); + logger.info('InviteService: Finished caching all invites.'); } public static async cacheGuildInvites(guild: Guild) { @@ -20,69 +24,151 @@ export class InviteService { })); await cache.set(`invites:${guild.id}`, JSON.stringify(inviteData)); } catch (error) { - logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error); + logger.error(`InviteService: 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}`); + logger.debug(`InviteService: 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); + 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); } 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 { - // Fetch current active invites - const newInvites = await guild.invites.fetch(); + const currentInvites = 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 usedInvite = currentInvites.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.`); - } + if (usedInvite) { + usedInviteCode = usedInvite.code; + // 즉시 캐시 업데이트 + await this.cacheGuildInvites(guild); } - } 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); + 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: { + guildId: guild.id, + inviteCode: usedInviteCode + } + } + }); + + 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 (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); + } } } }