244 lines
7.7 KiB
TypeScript
244 lines
7.7 KiB
TypeScript
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<string[]>;
|
|
}
|
|
|
|
interface HierarchyFeature {
|
|
featureKey: string;
|
|
scope: 'hierarchy';
|
|
/** DB에서 점검이 필요한 역할 ID 목록을 가져오는 함수 */
|
|
resolveTargetRoleIds: (guildId: string) => Promise<string[]>;
|
|
}
|
|
|
|
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<AuditResult[]> {
|
|
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<PermissionsBitField>,
|
|
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<Guild['members']['me']>,
|
|
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<PermissionsBitField> | 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<string, bigint>)[key] === perm,
|
|
) ?? perm.toString()
|
|
);
|
|
}
|
|
}
|