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' | 'INVITE' | '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 = { 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 { 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 { await prisma.auditChannel.upsert({ where: { guildId }, create: { guildId, channelId, // ๊ธฐ๋ณธ์ ์œผ๋กœ ๋ถ€ํŒ… ๋กœ๊ทธ(BOOT)์™€ ์‹œ์Šคํ…œ ๋กœ๊ทธ(SYSTEM)๋Š” ๋ฐ›์ง€ ์•Š๋„๋ก ์„ค์ • disabledCategories: ['BOOT', 'SYSTEM'] }, update: { channelId }, }); } /** * ์„œ๋ฒ„์˜ ๊ฐ์‚ฌ ์ฑ„๋„ ์„ค์ •์„ ํ•ด์ œํ•ฉ๋‹ˆ๋‹ค. */ async clearChannel(guildId: string): Promise { 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 { 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();