diff --git a/prisma/migrations/20260327062258_add_audit_channel_model/migration.sql b/prisma/migrations/20260327062258_add_audit_channel_model/migration.sql new file mode 100644 index 0000000..8890a92 --- /dev/null +++ b/prisma/migrations/20260327062258_add_audit_channel_model/migration.sql @@ -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") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4f7c1db..b443e46 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 +} diff --git a/src/commands/auditChannel.ts b/src/commands/auditChannel.ts new file mode 100644 index 0000000..885d873 --- /dev/null +++ b/src/commands/auditChannel.ts @@ -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)'}** 되었습니다.`, + }); + } + }, +}; diff --git a/src/commands/auditPermissions.ts b/src/commands/auditPermissions.ts index 0a30173..ac903fb 100644 --- a/src/commands/auditPermissions.ts +++ b/src/commands/auditPermissions.ts @@ -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 = { 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(() => {}); + } }, }; diff --git a/src/events/ready.ts b/src/events/ready.ts index 556ece7..a15248f 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -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(() => {}); + }); }, }; diff --git a/src/services/AuditLogService.ts b/src/services/AuditLogService.ts new file mode 100644 index 0000000..bb0270d --- /dev/null +++ b/src/services/AuditLogService.ts @@ -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 = { + 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` }); + + 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 }, + 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(); diff --git a/src/services/VoiceService.ts b/src/services/VoiceService.ts index 2ddeb83..9e877e6 100644 --- a/src/services/VoiceService.ts +++ b/src/services/VoiceService.ts @@ -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) {