refactor: remove invite management system and associated command, events, and localization keys

This commit is contained in:
이정수 2026-04-07 16:30:20 +09:00
parent 579e9a8a61
commit c4f5e8d53c
5 changed files with 0 additions and 409 deletions

View File

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

View File

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

View File

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

View File

@ -165,26 +165,6 @@ export interface TranslationSchema {
permissionsError: string; permissionsError: string;
suspendNotice: 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: { music: {
description: string; description: string;
addDescription: string; addDescription: string;
@ -302,8 +282,6 @@ export interface TranslationSchema {
VOICE_GLOBAL: string; VOICE_GLOBAL: string;
VOICE_GENERATOR_CHANNEL: string; VOICE_GENERATOR_CHANNEL: string;
VOICE_GENERATOR_CATEGORY: string; VOICE_GENERATOR_CATEGORY: string;
INVITE_TRACKING: string;
INVITE_ROLE_HIERARCHY: string;
MIMIC_WEBHOOK: string; MIMIC_WEBHOOK: string;
}; };
}; };
@ -325,7 +303,6 @@ export interface TranslationSchema {
BOOT: string; BOOT: string;
VOICE: string; VOICE: string;
PERMISSION: string; PERMISSION: string;
INVITE: string;
}; };
}; };
config: { config: {

View File

@ -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<string, Promise<void>>();
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);
}
}
}
}