Kord/apps/bot/src/services/AuditLogService.ts

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