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:
parent
f6feb9b83e
commit
234a0e96fe
|
|
@ -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")
|
||||||
|
);
|
||||||
|
|
@ -90,3 +90,11 @@ enum DeleteCondition {
|
||||||
OWNER_LEAVE
|
OWNER_LEAVE
|
||||||
EMPTY
|
EMPTY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AuditChannel {
|
||||||
|
guildId String @id
|
||||||
|
channelId String
|
||||||
|
disabledCategories String[] @default([])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)'}** 되었습니다.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { PermissionAuditService, AuditResult, AuditStatus } from '../services/PermissionAuditService';
|
import { PermissionAuditService, AuditResult, AuditStatus } from '../services/PermissionAuditService';
|
||||||
import { SupportedLocale, t } from '../i18n';
|
import { SupportedLocale, t } from '../i18n';
|
||||||
|
import { auditLogService } from '../services/AuditLogService';
|
||||||
|
|
||||||
const STATUS_EMOJI: Record<AuditStatus, string> = {
|
const STATUS_EMOJI: Record<AuditStatus, string> = {
|
||||||
SUCCESS: '✅',
|
SUCCESS: '✅',
|
||||||
|
|
@ -98,5 +99,22 @@ export default {
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
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(() => {});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { logger } from '../utils/logger';
|
||||||
import { InviteService } from '../services/InviteService';
|
import { InviteService } from '../services/InviteService';
|
||||||
import { VoiceService } from '../services/VoiceService';
|
import { VoiceService } from '../services/VoiceService';
|
||||||
import { PresenceService } from '../services/PresenceService';
|
import { PresenceService } from '../services/PresenceService';
|
||||||
|
import { auditLogService } from '../services/AuditLogService';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.ClientReady,
|
name: Events.ClientReady,
|
||||||
|
|
@ -21,5 +22,14 @@ export default {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to register global commands', 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(() => {});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -5,6 +5,7 @@ import { redis } from '../cache';
|
||||||
import { ErrorDefs } from '../errors/ErrorCodes';
|
import { ErrorDefs } from '../errors/ErrorCodes';
|
||||||
import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n';
|
import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n';
|
||||||
import { getContextLocale } from '../i18n/localeHelper';
|
import { getContextLocale } from '../i18n/localeHelper';
|
||||||
|
import { auditLogService } from './AuditLogService';
|
||||||
|
|
||||||
export class VoiceService {
|
export class VoiceService {
|
||||||
public static async syncChannels(client: Client) {
|
public static async syncChannels(client: Client) {
|
||||||
|
|
@ -98,6 +99,14 @@ export class VoiceService {
|
||||||
PermissionFlagsBits.MoveMembers
|
PermissionFlagsBits.MoveMembers
|
||||||
])) {
|
])) {
|
||||||
logger.error(`${ErrorDefs.BOT_MISSING_VOICE_PERMISSIONS.code}: Bot lacks required Voice permissions in guild ${guild.id}`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,6 +188,13 @@ export class VoiceService {
|
||||||
|
|
||||||
await member.voice.setChannel(newChannel);
|
await member.voice.setChannel(newChannel);
|
||||||
logger.info(`VoiceService: Created ${newChannel.name} for ${member.user.tag}`);
|
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);
|
await this.sendControlPanel(newChannel, member.id, locale);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -217,6 +233,12 @@ export class VoiceService {
|
||||||
await channel.delete();
|
await channel.delete();
|
||||||
await prisma.tempVoiceChannel.delete({ where: { channelId } });
|
await prisma.tempVoiceChannel.delete({ where: { channelId } });
|
||||||
logger.info(`VoiceService: Successfully deleted temp channel ${channel.name}`);
|
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) {
|
} catch (error: any) {
|
||||||
// If already deleted in Discord, just clean up DB
|
// If already deleted in Discord, just clean up DB
|
||||||
if (error.code === 10003 || error.status === 404) {
|
if (error.code === 10003 || error.status === 404) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue