feat: implement AutoRoleService with retroactive assignment and configuration support
This commit is contained in:
parent
e4bcf53308
commit
107c00cb13
|
|
@ -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");
|
||||||
|
|
@ -208,6 +208,31 @@ model FeverState {
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model RefinementLevelConfig {
|
||||||
level Int @id
|
level Int @id
|
||||||
successRate Float
|
successRate Float
|
||||||
|
|
|
||||||
|
|
@ -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<ButtonBuilder>().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<ButtonBuilder>().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<RoleSelectMenuBuilder>().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();
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { TranslationSchema } from '../types';
|
import { TranslationSchema } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* English translations ??the DEFAULT and FALLBACK locale.
|
* English translations ??the DEFAULT and FALLBACK locale.
|
||||||
|
|
@ -191,6 +191,46 @@ export const en: TranslationSchema = {
|
||||||
status: 'Status',
|
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: {
|
music: {
|
||||||
description: 'Play YouTube audio in voice channels.',
|
description: 'Play YouTube audio in voice channels.',
|
||||||
addDescription: 'Search YouTube or add a video URL to the queue.',
|
addDescription: 'Search YouTube or add a video URL to the queue.',
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { TranslationSchema } from '../types';
|
import { TranslationSchema } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ?쒓뎅??踰덉뿭 ?뚯씪.
|
* 한국어 번역 파일.
|
||||||
* 紐⑤뱺 ?ㅺ? en.ts? 1:1 ??묐릺?댁빞 ?⑸땲??
|
* 모든 키는 en.ts와 1:1 대응되어야 합니다.
|
||||||
*/
|
*/
|
||||||
export const ko: TranslationSchema = {
|
export const ko: TranslationSchema = {
|
||||||
// ?? ?먮윭 硫붿떆吏 ?????????????????????????????????????????
|
// 에러 메시지
|
||||||
errors: {
|
errors: {
|
||||||
E1001: {
|
E1001: {
|
||||||
userMessage: '사용자 제한 값이 올바르지 않습니다.',
|
userMessage: '사용자 제한 값이 올바르지 않습니다.',
|
||||||
|
|
@ -184,13 +184,53 @@ export const ko: TranslationSchema = {
|
||||||
reminderNone: '?먮룞 怨듭? ?놁쓬',
|
reminderNone: '?먮룞 怨듭? ?놁쓬',
|
||||||
announcementChannelNone: '미설정',
|
announcementChannelNone: '미설정',
|
||||||
fields: {
|
fields: {
|
||||||
eventId: '?대깽??ID',
|
eventId: '이벤트 ID',
|
||||||
startsAt: '?쒖옉 ?쒓컖',
|
startsAt: '시작 시각',
|
||||||
reminder: '由щ쭏?몃뜑',
|
reminder: '리마인더',
|
||||||
announcementChannel: '怨듭? 梨꾨꼸',
|
announcementChannel: '공지 채널',
|
||||||
status: '?곹깭',
|
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: {
|
music: {
|
||||||
description: 'Play YouTube audio in voice channels.',
|
description: 'Play YouTube audio in voice channels.',
|
||||||
addDescription: 'Search YouTube or add a video URL to the queue.',
|
addDescription: 'Search YouTube or add a video URL to the queue.',
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,46 @@ export interface TranslationSchema {
|
||||||
status: string;
|
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: {
|
music: {
|
||||||
description: string;
|
description: string;
|
||||||
addDescription: string;
|
addDescription: string;
|
||||||
|
|
|
||||||
|
|
@ -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<boolean> {
|
||||||
|
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();
|
||||||
|
|
@ -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 { cache } from '../cache';
|
||||||
import { prisma } from '../database';
|
import { prisma } from '../database';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import { auditLogService } from './AuditLogService';
|
||||||
|
import { autoRoleService } from './AutoRoleService';
|
||||||
|
|
||||||
export class InviteService {
|
export class InviteService {
|
||||||
|
private static processingQueue = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
public static async cacheAllInvites(client: Client) {
|
public static async cacheAllInvites(client: Client) {
|
||||||
for (const [, guild] of client.guilds.cache) {
|
for (const [, guild] of client.guilds.cache) {
|
||||||
await this.cacheGuildInvites(guild);
|
await this.cacheGuildInvites(guild);
|
||||||
}
|
}
|
||||||
logger.info('InviteMaster: Finished caching all invites.');
|
logger.info('InviteService: Finished caching all invites.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async cacheGuildInvites(guild: Guild) {
|
public static async cacheGuildInvites(guild: Guild) {
|
||||||
|
|
@ -20,69 +24,151 @@ export class InviteService {
|
||||||
}));
|
}));
|
||||||
await cache.set(`invites:${guild.id}`, JSON.stringify(inviteData));
|
await cache.set(`invites:${guild.id}`, JSON.stringify(inviteData));
|
||||||
} catch (error) {
|
} 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) {
|
public static async handleInviteCreate(invite: Invite) {
|
||||||
if (!invite.guild) return;
|
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);
|
await this.cacheGuildInvites(invite.guild as Guild);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async handleInviteDelete(invite: Invite) {
|
public static async handleInviteDelete(invite: Invite) {
|
||||||
if (!invite.guild) return;
|
if (!invite.guild || !(invite.guild instanceof Guild)) return;
|
||||||
logger.debug(`InviteMaster: Invite deleted: ${invite.code}`);
|
logger.debug(`InviteService: Invite deleted: ${invite.code}`);
|
||||||
await this.cacheGuildInvites(invite.guild as Guild);
|
|
||||||
|
// 초대 코드와 연결된 역할 매핑이 있는지 확인
|
||||||
|
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) {
|
public static async handleMemberAdd(member: GuildMember) {
|
||||||
const guild = member.guild;
|
const guild = member.guild;
|
||||||
try {
|
const queueKey = `join:${guild.id}`;
|
||||||
// Fetch current active invites
|
|
||||||
const newInvites = await guild.invites.fetch();
|
|
||||||
const cachedData = await cache.get(`invites:${guild.id}`);
|
|
||||||
|
|
||||||
let usedInvite: Invite | undefined;
|
// 동일 서버의 입장 이벤트를 순차적으로 처리하기 위한 큐
|
||||||
|
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 {
|
||||||
|
const currentInvites = await guild.invites.fetch();
|
||||||
|
const cachedData = await cache.get(`invites:${guild.id}`);
|
||||||
|
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
const cachedInvites: { code: string, uses: number }[] = JSON.parse(cachedData);
|
const cachedInvites: { code: string, uses: number }[] = JSON.parse(cachedData);
|
||||||
|
const usedInvite = currentInvites.find(inv => {
|
||||||
// Find the invite where 'uses' has increased
|
|
||||||
usedInvite = newInvites.find(inv => {
|
|
||||||
const cached = cachedInvites.find(c => c.code === inv.code);
|
const cached = cachedInvites.find(c => c.code === inv.code);
|
||||||
return cached ? (inv.uses || 0) > cached.uses : false;
|
return cached ? (inv.uses || 0) > cached.uses : false;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Update the cache immediately to account for this new join
|
|
||||||
await this.cacheGuildInvites(guild);
|
|
||||||
|
|
||||||
if (usedInvite) {
|
if (usedInvite) {
|
||||||
logger.info(`InviteMaster: ${member.user.tag} joined using invite ${usedInvite.code}`);
|
usedInviteCode = usedInvite.code;
|
||||||
|
// 즉시 캐시 업데이트
|
||||||
|
await this.cacheGuildInvites(guild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`InviteService: Failed to fetch invites for tracking in ${guild.id}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
// Check DB for mapped role
|
// 3. 초대 연동 역할 확인
|
||||||
const inviteRole = await prisma.inviteRole.findFirst({
|
if (usedInviteCode) {
|
||||||
|
const inviteRole = await prisma.inviteRole.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
guildId_inviteCode: {
|
||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
inviteCode: usedInvite.code
|
inviteCode: usedInviteCode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (inviteRole) {
|
if (inviteRole && !rolesToGive.includes(inviteRole.roleId)) {
|
||||||
const role = guild.roles.cache.get(inviteRole.roleId);
|
rolesToGive.push(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.`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.info(`InviteMaster: ${member.user.tag} joined but invite could not be determined (ex: Vanity URL).`);
|
// 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) {
|
} catch (error) {
|
||||||
logger.error(`InviteMaster: Failed to handle member add tracking:`, error);
|
logger.error(`InviteService: Failed to assign roles to ${member.user.tag}:`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue