feat: add invite command to manage role-based invite code mappings

This commit is contained in:
이정수 2026-04-06 15:05:23 +09:00
parent 107c00cb13
commit 26cfd356ff
2 changed files with 7055 additions and 4175 deletions

192
src/commands/invite.ts Normal file
View File

@ -0,0 +1,192 @@
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();

11038
yarn.lock

File diff suppressed because it is too large Load Diff