135 lines
4.2 KiB
TypeScript
135 lines
4.2 KiB
TypeScript
import { Guild, EmbedBuilder, TextChannel, Colors } from 'discord.js';
|
|
import { prisma } from '../database';
|
|
import { env } from '../config/env';
|
|
|
|
export type AuditSeverity = 'INFO' | 'WARN' | 'ERROR';
|
|
export type AuditCategory = 'SYSTEM' | 'BOOT' | 'VOICE' | 'PERMISSION' | 'MIMIC';
|
|
|
|
export interface AuditLogPayload {
|
|
category: AuditCategory;
|
|
severity: AuditSeverity;
|
|
title: string;
|
|
description: string;
|
|
fields?: { name: string; value: string; inline?: boolean }[];
|
|
errorCode?: string;
|
|
}
|
|
|
|
const SEVERITY_INFO: Record<AuditSeverity, { color: number; icon: string }> = {
|
|
INFO: { color: 0x5865f2, icon: '🔵' },
|
|
WARN: { color: 0xfee75c, icon: '🟡' },
|
|
ERROR: { color: 0xed4245, icon: '🔴' },
|
|
};
|
|
|
|
export class AuditLogService {
|
|
/**
|
|
* 감사 채널에 로그 메시지를 발송합니다.
|
|
* 지정된 카테고리가 채널의 필터(disabledCategories)에 포함되어 있거나
|
|
* 채널이 설정되지 않은 경우 조용히 무시(Silent Fail)합니다.
|
|
*/
|
|
async log(guild: Guild, payload: AuditLogPayload): Promise<void> {
|
|
try {
|
|
const config = await this.getChannel(guild.id);
|
|
if (!config) return;
|
|
|
|
// 카테고리 필터링 적용
|
|
if (config.disabledCategories.includes(payload.category)) {
|
|
return;
|
|
}
|
|
|
|
const channel = await guild.channels.fetch(config.channelId).catch(() => null);
|
|
if (!channel || !channel.isTextBased() || !(channel instanceof TextChannel)) {
|
|
return;
|
|
}
|
|
|
|
const { color, icon } = SEVERITY_INFO[payload.severity];
|
|
|
|
const embed = new EmbedBuilder()
|
|
.setTitle(`[Kord Audit Log - ${payload.category}]`)
|
|
.setDescription(`**${payload.title}**\n\n${payload.description}`)
|
|
.setColor(color)
|
|
.setTimestamp()
|
|
.setFooter({ text: `${icon} ${payload.severity} · Kord System [${env.INSTANCE_ID}]` });
|
|
|
|
if (payload.fields && payload.fields.length > 0) {
|
|
embed.addFields(payload.fields);
|
|
}
|
|
|
|
if (payload.errorCode) {
|
|
embed.addFields({ name: 'Error Code', value: `\`${payload.errorCode}\``, inline: true });
|
|
}
|
|
|
|
await channel.send({ embeds: [embed] });
|
|
} catch (error) {
|
|
// 전송 실패 시 메인 로직에 영향을 주지 않도록 격리 후 콘솔에만 기록
|
|
console.error(`[AuditLogService] Error logging to guild ${guild.id}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 서버의 감사 채널을 설정(Upsert)합니다.
|
|
*/
|
|
async setChannel(guildId: string, channelId: string): Promise<void> {
|
|
await prisma.auditChannel.upsert({
|
|
where: { guildId },
|
|
create: {
|
|
guildId,
|
|
channelId,
|
|
// 기본적으로 부팅 로그(BOOT)와 시스템 로그(SYSTEM)는 받지 않도록 설정
|
|
disabledCategories: ['BOOT', 'SYSTEM']
|
|
},
|
|
update: { channelId },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 서버의 감사 채널 설정을 해제합니다.
|
|
*/
|
|
async clearChannel(guildId: string): Promise<void> {
|
|
await prisma.auditChannel.deleteMany({
|
|
where: { guildId }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 현재 감사 채널 설정 정보를 가져옵니다.
|
|
*/
|
|
async getChannel(guildId: string) {
|
|
return prisma.auditChannel.findUnique({
|
|
where: { guildId }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 특정 카테고리의 수신 여부를 토글하거나 지정합니다.
|
|
* @param enable true면 수신(disabledCategories에서 제거), false면 차단(추가)
|
|
* @returns 조작된 결과 category 목록
|
|
*/
|
|
async setFilter(guildId: string, category: AuditCategory, enable: boolean): Promise<string[]> {
|
|
const config = await this.getChannel(guildId);
|
|
if (!config) {
|
|
throw new Error('Audit channel not configured');
|
|
}
|
|
|
|
let updatedCategories = [...config.disabledCategories];
|
|
|
|
if (enable) {
|
|
// 수신 설정 (비활성화 목록에서 제거)
|
|
updatedCategories = updatedCategories.filter(c => c !== category);
|
|
} else {
|
|
// 차단 설정 (비활성화 목록에 추가)
|
|
if (!updatedCategories.includes(category)) {
|
|
updatedCategories.push(category);
|
|
}
|
|
}
|
|
|
|
const updated = await prisma.auditChannel.update({
|
|
where: { guildId },
|
|
data: { disabledCategories: updatedCategories }
|
|
});
|
|
|
|
return updated.disabledCategories;
|
|
}
|
|
}
|
|
|
|
export const auditLogService = new AuditLogService();
|