feat: Implement an audit log system with a new `AuditChannel` model, `/audit-channel` command, and `AuditLogService` to log various application events.

This commit is contained in:
이정수 2026-03-27 15:30:03 +09:00
parent f6feb9b83e
commit 234a0e96fe
7 changed files with 354 additions and 0 deletions

View File

@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "AuditChannel" (
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"disabledCategories" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AuditChannel_pkey" PRIMARY KEY ("guildId")
);

View File

@ -90,3 +90,11 @@ enum DeleteCondition {
OWNER_LEAVE
EMPTY
}
model AuditChannel {
guildId String @id
channelId String
disabledCategories String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -0,0 +1,158 @@
import {
SlashCommandBuilder,
PermissionFlagsBits,
ChatInputCommandInteraction,
ChannelType,
EmbedBuilder,
Colors,
TextChannel,
} from 'discord.js';
import { auditLogService, AuditCategory } from '../services/AuditLogService';
import { SupportedLocale, t } from '../i18n';
export default {
data: new SlashCommandBuilder()
.setName('audit-channel')
.setDescription('Manage the audit log channel settings.')
.setDescriptionLocalizations({
ko: '감사 채널 설정을 관리합니다.',
})
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addSubcommand((subcommand) =>
subcommand
.setName('set')
.setDescription('Set the audit log channel.')
.setDescriptionLocalizations({ ko: '감사 채널을 설정합니다.' })
.addChannelOption((option) =>
option
.setName('channel')
.setDescription('The text channel to use for audit logs.')
.setDescriptionLocalizations({ ko: '감사 로그를 기록할 텍스트 채널' })
.addChannelTypes(ChannelType.GuildText)
.setRequired(true)
)
)
.addSubcommand((subcommand) =>
subcommand
.setName('clear')
.setDescription('Clear the audit log channel.')
.setDescriptionLocalizations({ ko: '감사 채널 설정을 해제합니다.' })
)
.addSubcommand((subcommand) =>
subcommand
.setName('status')
.setDescription('Check current audit log channel status.')
.setDescriptionLocalizations({ ko: '현재 감사 채널 설정 상태를 확인합니다.' })
)
.addSubcommand((subcommand) =>
subcommand
.setName('filter')
.setDescription('Enable or disable specific audit log categories.')
.setDescriptionLocalizations({ ko: '특정 종류의 감사 로그 수신 여부를 설정합니다.' })
.addStringOption((option) =>
option
.setName('category')
.setDescription('The category to manage')
.setDescriptionLocalizations({ ko: '설정할 카테고리' })
.setRequired(true)
.addChoices(
{ name: 'SYSTEM (Boot, Generic Errors)', value: 'SYSTEM' },
{ name: 'VOICE (Voice Channels)', value: 'VOICE' },
{ name: 'PERMISSION (Permission Issues)', value: 'PERMISSION' },
{ name: 'INVITE (Invite Tracking)', value: 'INVITE' },
{ name: 'MIMIC (Mimic Features)', value: 'MIMIC' }
)
)
.addBooleanOption((option) =>
option
.setName('enable')
.setDescription('True to receive logs for this category, False to ignore.')
.setDescriptionLocalizations({ ko: '해당 카테고리 수신 (true) 또는 차단 (false)' })
.setRequired(true)
)
),
async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) {
if (!interaction.guild) return;
const subcommand = interaction.options.getSubcommand();
await interaction.deferReply({ ephemeral: true });
if (subcommand === 'set') {
const channel = interaction.options.getChannel('channel', true);
// 권한 검증
const botMember = interaction.guild.members.me;
if (!botMember) return;
const textChannel = channel as TextChannel;
const perms = textChannel.permissionsFor(botMember);
if (!perms.has(PermissionFlagsBits.SendMessages) || !perms.has(PermissionFlagsBits.EmbedLinks)) {
await interaction.editReply({
content: `❌ 봇에게 <#${channel.id}> 채널의 \`메시지 보내기(Send Messages)\`\`링크 첨부(Embed Links)\` 권한을 부여해주세요.`,
});
return;
}
await auditLogService.setChannel(interaction.guild.id, channel.id);
await interaction.editReply({
content: `✅ 감사 채널이 <#${channel.id}>로 설정되었습니다.`,
});
// 테스트 로그 전송
await auditLogService.log(interaction.guild, {
category: 'SYSTEM',
severity: 'INFO',
title: 'Audit Channel Configured',
description: `This channel has been configured as the audit log channel by <@${interaction.user.id}>.`,
});
} else if (subcommand === 'clear') {
await auditLogService.clearChannel(interaction.guild.id);
await interaction.editReply({
content: `✅ 감사 채널 설정이 해제되었습니다.`,
});
} else if (subcommand === 'status') {
const config = await auditLogService.getChannel(interaction.guild.id);
if (!config) {
await interaction.editReply({
content: `설정된 감사 채널이 없습니다. 먼저 \`/audit-channel set\` 명령어로 채널을 설정해주세요.`,
});
return;
}
const disabled = config.disabledCategories.length > 0
? config.disabledCategories.map((c: string) => `\`${c}\``).join(', ')
: '없음 (모두 수신 중)';
const embed = new EmbedBuilder()
.setTitle('🔍 감사 채널 설정 상태')
.setColor(Colors.Blue)
.addFields(
{ name: '지정된 채널', value: `<#${config.channelId}>`, inline: false },
{ name: '수신 차단된 카테고리 (Muted)', value: disabled, inline: false }
)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
} else if (subcommand === 'filter') {
const category = interaction.options.getString('category', true) as AuditCategory;
const enable = interaction.options.getBoolean('enable', true);
const config = await auditLogService.getChannel(interaction.guild.id);
if (!config) {
await interaction.editReply({
content: `설정된 감사 채널이 없습니다. 먼저 \`/audit-channel set\` 명령어로 채널을 설정해주세요.`,
});
return;
}
const newFilters = await auditLogService.setFilter(interaction.guild.id, category, enable);
await interaction.editReply({
content: `✅ **${category}** 카테고리의 감사 로그 수신이 **${enable ? '활성화(ON)' : '비활성화(OFF)'}** 되었습니다.`,
});
}
},
};

View File

@ -7,6 +7,7 @@ import {
} from 'discord.js';
import { PermissionAuditService, AuditResult, AuditStatus } from '../services/PermissionAuditService';
import { SupportedLocale, t } from '../i18n';
import { auditLogService } from '../services/AuditLogService';
const STATUS_EMOJI: Record<AuditStatus, string> = {
SUCCESS: '✅',
@ -98,5 +99,22 @@ export default {
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
if (failCount > 0 || warnCount > 0) {
const issues = sorted.filter(r => r.status !== 'SUCCESS');
const descLines = issues.map(r => {
let text = `- **${r.featureKey}** [${r.status}]`;
if (r.missingPermissions.length > 0) text += `\n 누락: \`${r.missingPermissions.join('`, `')}\``;
if (r.scope === 'channel') text += `\n 채널: <#${r.channelId}>`;
return text;
});
await auditLogService.log(interaction.guild, {
category: 'PERMISSION',
severity: failCount > 0 ? 'ERROR' : 'WARN',
title: '권한 감사에서 문제 감지',
description: `\`/audit-permissions\` 실행 중 권한 문제가 확인되었습니다.\n\n${descLines.join('\n')}`
}).catch(() => {});
}
},
};

View File

@ -4,6 +4,7 @@ import { logger } from '../utils/logger';
import { InviteService } from '../services/InviteService';
import { VoiceService } from '../services/VoiceService';
import { PresenceService } from '../services/PresenceService';
import { auditLogService } from '../services/AuditLogService';
export default {
name: Events.ClientReady,
@ -21,5 +22,14 @@ export default {
} catch (e) {
logger.error('Failed to register global commands', e);
}
client.guilds.cache.forEach(guild => {
auditLogService.log(guild, {
category: 'SYSTEM',
severity: 'INFO',
title: 'Bot Online',
description: `Kord has successfully started or reconnected.`
}).catch(() => {});
});
},
};

View File

@ -0,0 +1,128 @@
import { Guild, EmbedBuilder, TextChannel, Colors } from 'discord.js';
import { prisma } from '../database';
export type AuditSeverity = 'INFO' | 'WARN' | 'ERROR';
export type AuditCategory = 'SYSTEM' | '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<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` });
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 },
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();

View File

@ -5,6 +5,7 @@ import { redis } from '../cache';
import { ErrorDefs } from '../errors/ErrorCodes';
import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n';
import { getContextLocale } from '../i18n/localeHelper';
import { auditLogService } from './AuditLogService';
export class VoiceService {
public static async syncChannels(client: Client) {
@ -98,6 +99,14 @@ export class VoiceService {
PermissionFlagsBits.MoveMembers
])) {
logger.error(`${ErrorDefs.BOT_MISSING_VOICE_PERMISSIONS.code}: Bot lacks required Voice permissions in guild ${guild.id}`);
auditLogService.log(guild, {
category: 'PERMISSION',
severity: 'ERROR',
title: '음성 채널 생성 권한 부족',
description: `임시 음성 채널 생성에 필요한 권한이 부족하여 생성을 중단했습니다.`,
fields: [{ name: '누락된 필수 권한', value: '`ManageChannels`, `ManageRoles`, `MoveMembers` 중 하나 이상' }],
errorCode: ErrorDefs.BOT_MISSING_VOICE_PERMISSIONS.code
}).catch(() => {});
return;
}
@ -179,6 +188,13 @@ export class VoiceService {
await member.voice.setChannel(newChannel);
logger.info(`VoiceService: Created ${newChannel.name} for ${member.user.tag}`);
auditLogService.log(guild, {
category: 'VOICE',
severity: 'INFO',
title: '임시 음성 채널 생성',
description: `유저 <@${member.id}> 님이 임시 채널을 생성했습니다.`,
fields: [{ name: '채널', value: `<#${newChannel.id}>`, inline: true }]
}).catch(() => {});
await this.sendControlPanel(newChannel, member.id, locale);
} catch (error) {
@ -217,6 +233,12 @@ export class VoiceService {
await channel.delete();
await prisma.tempVoiceChannel.delete({ where: { channelId } });
logger.info(`VoiceService: Successfully deleted temp channel ${channel.name}`);
auditLogService.log(channel.guild, {
category: 'VOICE',
severity: 'INFO',
title: '임시 음성 채널 삭제',
description: `조건 충족으로 임시 채널이 삭제되었습니다.\n채널 이름: **${channel.name}**`
}).catch(() => {});
} catch (error: any) {
// If already deleted in Discord, just clean up DB
if (error.code === 10003 || error.status === 404) {