import { Guild, PermissionFlagsBits, PermissionsBitField, GuildBasedChannel, Role, } from 'discord.js'; import { prisma } from '../database'; import { logger } from '../utils/logger'; // ─── Types ──────────────────────────────────────────────────────────────────── export type AuditStatus = 'SUCCESS' | 'WARNING' | 'FAIL'; export interface AuditResult { featureKey: string; status: AuditStatus; missingPermissions: string[]; scope: 'guild' | 'channel' | 'hierarchy'; channelId?: string; /** 역할 계층 관련 추가 정보 */ hierarchyInfo?: { botRolePosition: number; targetRoleId: string; targetRolePosition: number; targetRoleName: string; }; } // ─── Feature Definitions (Plugin-style) ─────────────────────────────────────── // 새 기능이 추가될 때 이 배열에 항목을 추가하기만 하면 자동으로 진단 대상에 포함됩니다. interface GuildFeature { featureKey: string; scope: 'guild'; permissions: bigint[]; } interface ChannelFeature { featureKey: string; scope: 'channel'; permissions: bigint[]; /** DB에서 채널 ID 목록을 가져오는 함수 */ resolveChannelIds: (guildId: string) => Promise; } interface HierarchyFeature { featureKey: string; scope: 'hierarchy'; /** DB에서 점검이 필요한 역할 ID 목록을 가져오는 함수 */ resolveTargetRoleIds: (guildId: string) => Promise; } type FeatureDefinition = GuildFeature | ChannelFeature | HierarchyFeature; const FEATURE_DEFINITIONS: FeatureDefinition[] = [ // ── 1. 공통 (Basic) ── { featureKey: 'BASIC', scope: 'guild', permissions: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks, ], }, // ── 2. 임시 음성 채널 (전역) ── { featureKey: 'VOICE_GLOBAL', scope: 'guild', permissions: [ PermissionFlagsBits.ManageChannels, PermissionFlagsBits.MoveMembers, PermissionFlagsBits.Connect, ], }, // ── 3. 임시 음성 채널 (생성기 채널 레벨) ── { featureKey: 'VOICE_GENERATOR_CHANNEL', scope: 'channel', permissions: [ PermissionFlagsBits.ManageChannels, PermissionFlagsBits.MoveMembers, PermissionFlagsBits.ManageRoles, PermissionFlagsBits.Connect, ], resolveChannelIds: async (guildId) => { const generators = await prisma.voiceGenerator.findMany({ where: { guildId } }); return generators.map((g) => g.channelId); }, }, // ── 4. 임시 음성 채널 (카테고리 레벨) ── { featureKey: 'VOICE_GENERATOR_CATEGORY', scope: 'channel', permissions: [ PermissionFlagsBits.ManageChannels, PermissionFlagsBits.MoveMembers, PermissionFlagsBits.ManageRoles, ], resolveChannelIds: async (guildId) => { const generators = await prisma.voiceGenerator.findMany({ where: { guildId } }); return generators.map((g) => g.categoryId).filter((id): id is string => id !== null); }, }, // ── 5. 초대 추적 ── { featureKey: 'INVITE_TRACKING', scope: 'guild', permissions: [PermissionFlagsBits.ManageGuild], }, // ── 6. 역할 자동 부여 (초대 연동) - 계층 검사 ── { featureKey: 'INVITE_ROLE_HIERARCHY', scope: 'hierarchy', resolveTargetRoleIds: async (guildId) => { const inviteRoles = await prisma.inviteRole.findMany({ where: { guildId } }); return inviteRoles.map((ir) => ir.roleId); }, }, // ── 7. 메시지 흉내 (Mimic) ── { featureKey: 'MIMIC_WEBHOOK', scope: 'guild', permissions: [ PermissionFlagsBits.ManageWebhooks, PermissionFlagsBits.ManageMessages, ], }, // ↓ 향후 기능 추가 시 여기에 항목을 추가하세요 ↓ // e.g., Audit Log Channel, Setup Wizard 등 ]; // ─── Service ────────────────────────────────────────────────────────────────── export class PermissionAuditService { public static async auditGuild(guild: Guild): Promise { const botMember = guild.members.me; if (!botMember) return []; const results: AuditResult[] = []; for (const feature of FEATURE_DEFINITIONS) { try { if (feature.scope === 'guild') { results.push(this.checkGuildPermissions(botMember.permissions, feature)); } else if (feature.scope === 'channel') { const channelIds = await feature.resolveChannelIds(guild.id); for (const channelId of channelIds) { const channel = await guild.channels.fetch(channelId).catch(() => null); if (channel) { results.push(this.checkChannelPermissions(channel, botMember, feature)); } } } else if (feature.scope === 'hierarchy') { const roleIds = await feature.resolveTargetRoleIds(guild.id); for (const roleId of roleIds) { const role = guild.roles.cache.get(roleId) ?? await guild.roles.fetch(roleId).catch(() => null); if (role) { results.push(this.checkHierarchy(guild, botMember.roles.highest, role, feature.featureKey)); } } } } catch (err) { logger.error(`PermissionAuditService: Error checking feature "${feature.featureKey}"`, err); } } return results; } // ── Private Helpers ──────────────────────────────────────────────────────── private static checkGuildPermissions( currentPerms: Readonly, feature: GuildFeature, ): AuditResult { const missing = this.getMissing(currentPerms, feature.permissions); return { featureKey: feature.featureKey, status: missing.length > 0 ? 'FAIL' : 'SUCCESS', missingPermissions: missing, scope: 'guild', }; } private static checkChannelPermissions( channel: GuildBasedChannel, botMember: NonNullable, feature: ChannelFeature, ): AuditResult { const channelPerms = channel.permissionsFor(botMember); const missing = this.getMissing(channelPerms, feature.permissions); return { featureKey: feature.featureKey, status: missing.length > 0 ? 'WARNING' : 'SUCCESS', missingPermissions: missing, scope: 'channel', channelId: channel.id, }; } private static checkHierarchy( guild: Guild, botHighestRole: Role, targetRole: Role, featureKey: string, ): AuditResult { const isOk = botHighestRole.position > targetRole.position; return { featureKey, status: isOk ? 'SUCCESS' : 'FAIL', missingPermissions: [], scope: 'hierarchy', hierarchyInfo: { botRolePosition: botHighestRole.position, targetRoleId: targetRole.id, targetRolePosition: targetRole.position, targetRoleName: targetRole.name, }, }; } private static getMissing(perms: Readonly | null, required: bigint[]): string[] { if (!perms) return required.map(this.permToString); return required.filter((p) => !perms.has(p)).map(this.permToString); } private static permToString(perm: bigint): string { return ( Object.keys(PermissionFlagsBits).find( (key) => (PermissionFlagsBits as Record)[key] === perm, ) ?? perm.toString() ); } }