diff --git a/src/commands/invite.ts b/src/commands/invite.ts deleted file mode 100644 index 3e14791..0000000 --- a/src/commands/invite.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { - SlashCommandBuilder, - ChatInputCommandInteraction, - PermissionFlagsBits, - EmbedBuilder, - Colors, - RoleSelectMenuBuilder, - ActionRowBuilder -} from 'discord.js'; -import { Command, CommandTrait } from '../core/command'; -import { prisma } from '../database'; -import { t, SupportedLocale } from '../i18n'; -import { InviteService } from '../services/InviteService'; -import { auditLogService } from '../services/AuditLogService'; - -class InviteCommand extends Command { - protected override readonly trait = CommandTrait.General; - protected override guildOnly = true; - - protected override define() { - return new SlashCommandBuilder() - .setName('invite') - .setDescription('Manage roles mapped to invite codes.') - .setDescriptionLocalizations({ - ko: '초대 코드와 역할을 연동하여 관리합니다.', - }) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .addSubcommand(subcommand => - subcommand - .setName('list') - .setDescription('List all invite codes in the server.') - .setDescriptionLocalizations({ ko: '서버의 초대 코드 목록을 조회합니다.' }) - .addStringOption(option => - option.setName('filter') - .setDescription('Lookup filter') - .addChoices( - { name: 'all', value: 'all' }, - { name: 'managed', value: 'managed' } - ) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('link') - .setDescription('Link a role to an existing invite code.') - .setDescriptionLocalizations({ ko: '기존 초대 코드에 역할을 연동합니다.' }) - .addStringOption(option => - option.setName('code') - .setDescription('Invite code string') - .setRequired(true) - ) - .addRoleOption(option => - option.setName('role') - .setDescription('Role to assign') - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('create') - .setDescription('Create a new invite code with a mapped role.') - .setDescriptionLocalizations({ ko: '역할이 연동된 새로운 초대 코드를 생성합니다.' }) - .addRoleOption(option => - option.setName('role') - .setDescription('Role to assign') - .setRequired(true) - ) - .addIntegerOption(option => - option.setName('max_uses') - .setDescription('Maximum uses') - ) - .addIntegerOption(option => - option.setName('max_age') - .setDescription('Expiration (seconds)') - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('unlink') - .setDescription('Unlink a role from an invite code.') - .setDescriptionLocalizations({ ko: '초대 코드에 연동된 역할을 해제합니다.' }) - .addStringOption(option => - option.setName('code') - .setDescription('Invite code string') - .setRequired(true) - ) - ); - } - - protected override async handle(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { - const subcommand = interaction.options.getSubcommand(); - const guild = interaction.guild!; - - if (subcommand === 'list') { - const filter = interaction.options.getString('filter') || 'managed'; - const mappings = await prisma.inviteRole.findMany({ where: { guildId: guild.id } }); - - let displayInvites: any[] = []; - if (filter === 'all') { - const invites = await guild.invites.fetch(); - displayInvites = invites.map(inv => ({ - code: inv.code, - roleId: mappings.find(m => m.inviteCode === inv.code)?.roleId, - uses: inv.uses, - maxUses: inv.maxUses, - })); - } else { - displayInvites = mappings.map(m => ({ - code: m.inviteCode, - roleId: m.roleId, - })); - } - - if (displayInvites.length === 0) { - await interaction.reply({ content: t(locale, 'commands.invite.listEmpty'), ephemeral: true }); - return; - } - - const embed = new EmbedBuilder() - .setTitle(t(locale, 'commands.invite.listTitle')) - .setColor(Colors.Blue) - .setDescription(displayInvites.map(inv => `\`${inv.code}\`: ${inv.roleId ? `<@&${inv.roleId}>` : t(locale, 'autorole.notSet')}`).join('\n')); - - await interaction.reply({ embeds: [embed], ephemeral: true }); - return; - } - - if (subcommand === 'link') { - const code = interaction.options.getString('code', true); - const role = interaction.options.getRole('role', true); - - await prisma.inviteRole.upsert({ - where: { - guildId_inviteCode: { - guildId: guild.id, - inviteCode: code, - }, - }, - update: { roleId: role.id }, - create: { - guildId: guild.id, - inviteCode: code, - roleId: role.id, - }, - }); - - await InviteService.cacheGuildInvites(guild); - await interaction.reply({ content: t(locale, 'commands.invite.linkSuccess', { code, role: role.name }), ephemeral: true }); - return; - } - - if (subcommand === 'create') { - const role = interaction.options.getRole('role', true); - const maxUses = interaction.options.getInteger('max_uses') || 0; - const maxAge = interaction.options.getInteger('max_age') || 0; - - const invite = await guild.invites.create(interaction.channelId, { - maxUses, - maxAge, - unique: true, - }); - - await prisma.inviteRole.create({ - data: { - guildId: guild.id, - inviteCode: invite.code, - roleId: role.id, - }, - }); - - await InviteService.cacheGuildInvites(guild); - await interaction.reply({ content: t(locale, 'commands.invite.createSuccess', { code: invite.code, role: role.name }), ephemeral: true }); - return; - } - - if (subcommand === 'unlink') { - const code = interaction.options.getString('code', true); - await prisma.inviteRole.deleteMany({ - where: { - guildId: guild.id, - inviteCode: code, - }, - }); - - await InviteService.cacheGuildInvites(guild); - await interaction.reply({ content: t(locale, 'commands.invite.unlinkSuccess', { code }), ephemeral: true }); - return; - } - } -} - -export default new InviteCommand().toModule(); 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/i18n/types.ts b/src/i18n/types.ts index dcc113c..2a3eb1b 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -165,26 +165,6 @@ export interface TranslationSchema { 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; @@ -302,8 +282,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; }; }; @@ -325,7 +303,6 @@ export interface TranslationSchema { BOOT: string; VOICE: string; PERMISSION: string; - INVITE: string; }; }; config: { diff --git a/src/services/InviteService.ts b/src/services/InviteService.ts deleted file mode 100644 index 502c309..0000000 --- a/src/services/InviteService.ts +++ /dev/null @@ -1,174 +0,0 @@ -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('InviteService: 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(`InviteService: Failed to cache invites for guild ${guild.id}:`, error); - } - } - - public static async handleInviteCreate(invite: Invite) { - if (!invite.guild) return; - logger.debug(`InviteService: New invite created: ${invite.code}`); - await this.cacheGuildInvites(invite.guild as Guild); - } - - public static async handleInviteDelete(invite: Invite) { - 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) { - const roleIds = member.user.bot ? autoRoleConfig.botRoleIds : autoRoleConfig.userRoleIds; - if (roleIds && roleIds.length > 0) { - rolesToGive.push(...roleIds); - } - } - - // 2. 초대 코드 식별 - let usedInviteCode: string | undefined; - try { - const currentInvites = await guild.invites.fetch(); - const cachedData = await cache.get(`invites:${guild.id}`); - - if (cachedData) { - const cachedInvites: { code: string, uses: number }[] = JSON.parse(cachedData); - const usedInvite = currentInvites.find(inv => { - const cached = cachedInvites.find(c => c.code === inv.code); - return cached ? (inv.uses || 0) > cached.uses : false; - }); - - if (usedInvite) { - usedInviteCode = usedInvite.code; - // 즉시 캐시 업데이트 - await this.cacheGuildInvites(guild); - } - } - } catch (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); - } - } - } -}