refactor: remove invite management system and associated command, events, and localization keys
This commit is contained in:
parent
579e9a8a61
commit
c4f5e8d53c
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue